From f31770d79c94e47ee8e66d2b252ef19bb2e80654 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 15:02:47 +0300 Subject: [PATCH] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=20D.12=20=E2=80=94=20fillTableRow=20=E2=86=92=20row-fill.mjs,?= =?UTF-8?q?=20deleteTableRow=20=E2=86=92=20grid.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit table/row-fill.mjs (~1230 LOC): fillTableRow целиком, как entry-point. table/grid.mjs: добавлен deleteTableRow + расширены импорты (clickElement, dismissPendingErrors, waitForStable, getFormState). Дальнейший распил fillTableRow на под-хелперы (trySelect/readVisibleRows/ pickValueWithOptionalType/enterEditMode/fillCellSequentially per плана §12.1-12.3) отложен — при необходимости создаём table/row-fill/*.mjs subfolder. browser.mjs: 1532 → 249 LOC (-84% от baseline 6293). 05-table регресс зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 1292 +---------------- .../skills/web-test/scripts/table/grid.mjs | 81 ++ .../web-test/scripts/table/row-fill.mjs | 1235 ++++++++++++++++ 3 files changed, 1321 insertions(+), 1287 deletions(-) create mode 100644 .claude/skills/web-test/scripts/table/row-fill.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 5f36b4da..29053f74 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -218,1293 +218,11 @@ export { closeForm } from './forms/close.mjs'; -/** - * Fill cells in the current table row via Tab navigation. - * Grid cells are only accessible sequentially (Tab) — no random access. - * - * After "Добавить", 1C enters inline edit mode on the first cell. - * All inputs in the row are created hidden (offsetWidth=0); only the active one is visible. - * Tab moves through cells in a fixed order determined by the form configuration. - * - * @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура") - * @param {Object} [options] - * @param {string} [options.tab] - Switch to this form tab before operating - * @param {boolean} [options.add] - Click "Добавить" to create a new row first - * @returns {{ filled[], notFilled[]?, form }} - */ -export async function fillTableRow(fields, { tab, add, row, table } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('fillTableRow: no form found'); - - // Pre-resolve grid when table is specified - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - - try { - // 1. Switch tab if requested - if (tab) { - await clickElement(tab); - } - - // 2. Add new row if requested - let addedRowIdx = -1; - if (add) { - // Count rows before add — new row will be appended at this index - addedRowIdx = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - const body = grid?.querySelector('.gridBody'); - return body ? body.querySelectorAll('.gridLine').length : 0; - })()`); - await clickElement('Добавить', { table }); - // Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait - for (let aw = 0; aw < 6; aw++) { - await page.waitForTimeout(150); - const ready = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); - if (ready) break; - } - } - - // 2b. Enter edit mode on existing row by dblclick - if (row != null) { - // Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right - const sortedKeys = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - if (!head) return null; - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); - const ci = parseInt(box.getAttribute('colindex') || '-1'); - if (t) cols.push({ text: t, colindex: ci }); - }); - const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))}; - const mapped = keys.map(k => { - const exact = cols.find(c => c.text === k); - if (exact) return { key: k, colindex: exact.colindex }; - const inc = cols.find(c => c.text.includes(k) || k.includes(c.text)); - return { key: k, colindex: inc ? inc.colindex : 999 }; - }); - mapped.sort((a, b) => a.colindex - b.colindex); - return mapped.map(m => m.key); - })()`); - if (sortedKeys) { - // Rebuild fields in sorted order - const sortedFields = {}; - for (const kl of sortedKeys) { - const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl); - if (origKey) sortedFields[origKey] = fields[origKey]; - } - // Add any keys not matched in header (preserve original order for those) - for (const k of Object.keys(fields)) { - if (!(k in sortedFields)) sortedFields[k] = fields[k]; - } - fields = sortedFields; - } - - const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); - const cellCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return { error: 'no_grid' }; - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - if (!head || !body) return { error: 'no_grid_body' }; - - // Read column headers to find target colindex - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); - }); - - const keys = ${fieldKeys}; - let targetColindex = null; - for (const key of keys) { - const exact = cols.find(c => c.text === key); - if (exact) { targetColindex = exact.colindex; break; } - const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); - if (inc) { targetColindex = inc.colindex; break; } - } - - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; - const line = rows[${row}]; - - // Find body cell by colindex (reliable across merged headers) - let box = null; - if (targetColindex != null) { - box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); - } - // Fallback: second visible box (skip checkbox/N column) - if (!box) { - const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - box = boxes.length > 1 ? boxes[1] : boxes[0]; - } - if (!box) return { error: 'no_cell' }; - // Scroll into view if off-screen - box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; - })()`); - - if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); - - // Skip if cell already contains the desired value (single-field optimization) - const firstKey0 = Object.keys(fields)[0]; - const rawFirstVal = fields[firstKey0]; - const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === '' - ? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal)); - let firstFieldSkipped = false; - if (cellCoords.currentText && firstVal0 && - cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) { - firstFieldSkipped = true; - if (Object.keys(fields).length === 1) { - return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }]; - } - } - - // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse). - // Then escalate: dblclick → F4 if needed. - await page.mouse.click(cellCoords.x, cellCoords.y); - - // Clear cell via Shift+F4 if value is empty - if (firstVal0 === '') { - await page.waitForTimeout(500); - // Check if click opened a selection form — close it first - let openedForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (openedForm !== null) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } else { - // No form opened — need to enter edit mode first (dblclick), then close any form that opens - await page.mouse.dblclick(cellCoords.x, cellCoords.y); - await page.waitForTimeout(500); - openedForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (openedForm !== null) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } - } - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }]; - // If more fields remain, process them on the same row - const remaining = { ...fields }; - delete remaining[firstKey0]; - if (Object.keys(remaining).length > 0) { - const more = await fillTableRow(remaining, { row, table }); - if (Array.isArray(more)) results.push(...more); - else if (more?.filled) results.push(...more.filled); - } - const formData = await getFormState(); - return { filled: results, form: formData }; - } - - // Check if clicked cell is a checkbox (toggle-on-click, no edit mode) - const checkboxInfo = await page.evaluate(`(() => { - const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y}); - const cell = el?.closest('.gridBox'); - if (!cell) return null; - const chk = cell.querySelector('.checkbox'); - if (!chk) return null; - const r = chk.getBoundingClientRect(); - return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; - })()`); - if (checkboxInfo !== null) { - // Checkbox cell found — click directly on the checkbox icon (not cell center) - const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim()); - if (checkboxInfo.checked !== desired) { - await page.mouse.click(checkboxInfo.x, checkboxInfo.y); - await page.waitForTimeout(300); - } - const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }]; - await waitForStable(formNum); - // If more fields remain, process them on the same row - const remaining = { ...fields }; - delete remaining[firstKey0]; - if (Object.keys(remaining).length > 0) { - const more = await fillTableRow(remaining, { row, table }); - results.push(...more); - } - return results; - } - - let inEdit = false; - let directEditForm = null; - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); - if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - // Click didn't enter edit — try dblclick (works for flat grids) - if (!inEdit && directEditForm === null) { - await page.mouse.dblclick(cellCoords.x, cellCoords.y); - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); - if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - } - // Still nothing — try F4 (opens selection for direct-edit cells) - if (!inEdit && directEditForm === null) { - await page.keyboard.press('F4'); - for (let fw = 0; fw < 8; fw++) { - await page.waitForTimeout(200); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); - if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - } - - // When click entered INPUT mode but no selection form yet — try F4 only for tree grids - // (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop) - if (inEdit && directEditForm === null) { - const isTreeGrid = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - return grid ? !!grid.querySelector('.gridBoxTree') : false; - })()`); - if (isTreeGrid) { - await page.keyboard.press('F4'); - for (let fw = 0; fw < 8; fw++) { - await page.waitForTimeout(200); - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - // If F4 didn't open a selection form, fall through to Tab loop - } - } - - // Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing). - // Handle each field by picking from selection form, then dblclick next cell. - if (directEditForm !== null) { - const pending = new Map(); - for (const [key, val] of Object.entries(fields)) { - if (val && typeof val === 'object' && 'value' in val) { - pending.set(key, { value: String(val.value), type: val.type || null, filled: false }); - } else { - pending.set(key, { value: String(val), type: null, filled: false }); - } - } - const results = []; - - // Helper: handle type dialog + pick from selection form - async function directEditPick(openedForm, key, info) { - let selForm = openedForm; - // Check if opened form is a type selection dialog (composite type field) - if (await isTypeDialog(selForm)) { - if (info.type) { - await pickFromTypeDialog(selForm, info.type); - await waitForStable(selForm); - // After type selection, detect the actual selection form - selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null) { - return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; - } - } else { - // No type specified — close type dialog and report error - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; - } - } - const pr = await pickFromSelectionForm(selForm, key, info.value, formNum); - return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message }; - } - - // First field: selection form is already open from the dblclick above - const firstKey = Object.keys(fields)[0]; - const firstInfo = pending.get(firstKey); - if (firstFieldSkipped) { - firstInfo.filled = true; - results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText }); - // Close the selection form that opened from the click - await page.keyboard.press('Escape'); - await waitForStable(formNum); - } else { - const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); - firstInfo.filled = true; - results.push(pickResult); - } - - // Remaining fields: dblclick on each column cell individually - for (const [key, info] of pending) { - if (info.filled) continue; - // Find column for this key and dblclick on it - const nextCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - if (!head || !body) return null; - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); - }); - const kl = ${JSON.stringify(key.toLowerCase())}; - const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); - let targetColindex = null; - const exact = cols.find(c => c.text === kl); - if (exact) targetColindex = exact.colindex; - else { - const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) - || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); - if (inc) targetColindex = inc.colindex; - } - if (targetColindex == null) return null; - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return null; - const line = rows[${row}]; - const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); - if (!box) return null; - box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; - })()`); - if (!nextCoords) { - info.filled = true; - results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); - continue; - } - // Skip if cell already contains the desired value - if (nextCoords.currentText && info.value && - nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) { - info.filled = true; - results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText }); - continue; - } - await page.mouse.dblclick(nextCoords.x, nextCoords.y); - await page.waitForTimeout(300); - // Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator - const inInputAfterDblclick = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); - // Also check if a selection form already appeared - let selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null && inInputAfterDblclick) { - // Plain text/numeric field — fill via clipboard paste - await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); - await page.waitForTimeout(400); - // Dismiss EDD autocomplete if it appeared - const hasEdd = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - return edd && edd.offsetWidth > 0; - })()`); - if (hasEdd) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(200); - } - info.filled = true; - results.push({ field: key, ok: true, method: 'paste' }); - continue; - } - // Poll for selection form (with F4 fallback if dblclick didn't open it) - if (selForm === null) { - for (let attempt = 0; attempt < 2 && selForm === null; attempt++) { - if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback - for (let sw = 0; sw < 6; sw++) { - await page.waitForTimeout(200); - selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm !== null) break; - } - } - } - if (selForm === null) { - info.filled = true; - results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); - continue; - } - const pr = await directEditPick(selForm, key, info); - info.filled = true; - results.push(pr); - } - // Commit the edit: click on a different row (Escape cancels in tree grids). - // Find the first visible row that is NOT the edited row and click it. - const commitCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const body = grid.querySelector('.gridBody'); - if (!body) return null; - const rows = [...body.querySelectorAll('.gridLine')]; - const otherIdx = ${row} === 0 ? 1 : 0; - const other = rows[otherIdx]; - if (!other) return null; - const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; - if (!box) return null; - const r = box.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (commitCoords) { - await page.mouse.click(commitCoords.x, commitCoords.y); - } else { - await page.keyboard.press('Escape'); - } - await waitForStable(formNum); - return results; - } - - if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); - } else { - // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent) - const editCheck = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; - let node = f; - while (node) { - if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; - node = node.parentElement; - } - return { inEdit: false, hint: 'input not inside grid' }; - })()`); - - if (!editCheck.inEdit) { - throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); - } - } - - // 4. Prepare pending fields for fuzzy matching - const pending = new Map(); - for (const [key, val] of Object.entries(fields)) { - if (val === null || val === undefined || val === '') { - pending.set(key, { value: '', type: null, filled: false }); - } else if (val && typeof val === 'object' && 'value' in val) { - const innerVal = val.value; - pending.set(key, { - value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal), - type: val.type || null, filled: false - }); - } else { - pending.set(key, { value: String(val), type: null, filled: false }); - } - } - - const results = []; - const MAX_ITER = 40; - let prevCellId = null; - let nonInputCount = 0; - let firstCellId = null; - - for (let iter = 0; iter < MAX_ITER; iter++) { - // Read focused element (INPUT or TEXTAREA inside grid = editable cell) - const cell = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f) return { tag: 'none' }; - if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { - const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })(); - if (inGrid) { - let headerText = ''; - let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement; - if (grid) { - const fr = f.getBoundingClientRect(); - const head = grid.querySelector('.gridHead'); - const hl = head?.querySelector('.gridLine') || head; - if (hl) for (const h of hl.children) { - if (h.offsetWidth === 0) continue; - const hr = h.getBoundingClientRect(); - if (fr.x >= hr.x && fr.x < hr.x + hr.width) { - const t = h.querySelector('.gridBoxText'); - headerText = (t || h).innerText?.trim() || ''; - break; - } - } - } - return { - tag: 'INPUT', id: f.id, - fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''), - headerText - }; - } - } - return { tag: f.tagName || 'none' }; - })()`); - - if (cell.tag !== 'INPUT' || !cell.fullName) { - // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) - nonInputCount++; - // If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows - const onlyCheckboxLeft = [...pending.values()].every(p => p.filled || - ['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim())); - if (nonInputCount > 3 || onlyCheckboxLeft) break; - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - continue; - } - nonInputCount = 0; - - // Track first cell to detect wrap-around (Tab looped back to row start) - if (firstCellId === null) firstCellId = cell.id; - else if (cell.id === firstCellId) break; // wrapped around — all cells visited - - // Stuck detection: same cell twice in a row → force Tab - if (cell.id === prevCellId) { - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - prevCellId = null; - continue; - } - prevCellId = cell.id; - - // Fuzzy match cell name to user field: exact → suffix → includes → no-space includes - const cellLower = cell.fullName.toLowerCase(); - let matchedKey = null; - for (const [key, info] of pending) { - if (info.filled) continue; - const kl = key.toLowerCase(); - if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) { - matchedKey = key; - break; - } - // CamelCase cell names have no spaces/dashes — try matching without spaces and dashes - const klNoSpace = kl.replace(/[\s\-]+/g, ''); - if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) { - matchedKey = key; - break; - } - } - - // Fallback: match by column header text (handles metadata typos in cell id) - if (!matchedKey && cell.headerText) { - const htLower = cell.headerText.toLowerCase(); - for (const [key, info] of pending) { - if (info.filled) continue; - const kl = key.toLowerCase(); - if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) { - matchedKey = key; - break; - } - } - } - - if (!matchedKey) { - // Skip this cell - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - continue; - } - - const info = pending.get(matchedKey); - const text = info.value; - - // Clear cell if value is empty (Shift+F4 = native 1C clear) - if (text === '') { - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' }); - if ([...pending.values()].every(p => p.filled)) break; - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // If user specified a type, always clear and use type selection flow - if (info.type) { - await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type - await page.waitForTimeout(300); - await page.keyboard.press('F4'); - // Poll for type dialog form to appear - let typeForm = null; - for (let tw = 0; tw < 6; tw++) { - await page.waitForTimeout(200); - typeForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (typeForm !== null) break; - } - if (typeForm !== null && await isTypeDialog(typeForm)) { - await pickFromTypeDialog(typeForm, info.type); - await waitForStable(typeForm); - // After type selection, check if a selection form opened (ref types) - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null) { - // Primitive type — poll for calculator/calendar popup or settle on INPUT - let hasPopup = null; - for (let pw = 0; pw < 5; pw++) { - await page.waitForTimeout(200); - hasPopup = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return 'calculator'; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return 'calendar'; - return null; - })()`); - if (hasPopup) break; - } - if (hasPopup) { - await page.keyboard.press('Escape'); - // Poll for popup to disappear - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - const gone = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return false; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return false; - return true; - })()`); - if (gone) break; - } - } - // Ensure we are in an editable INPUT for this cell - const inInput = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (!inInput) { - const cellRect = await page.evaluate(`(() => { - const el = document.getElementById(${JSON.stringify(cell.id)}); - if (!el) return null; - const r = el.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; - })()`); - if (cellRect) { - await page.mouse.dblclick(cellRect.x, cellRect.y); - // Poll for INPUT focus - for (let fw = 0; fw < 4; fw++) { - await page.waitForTimeout(150); - const ok = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (ok) break; - } - } - } - await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); - await page.waitForTimeout(400); - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); - continue; - } - const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); - info.filled = true; - results.push(pickResult.ok - ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } - : { field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - continue; - } - // F4 opened something but not a type dialog — close and report - if (typeForm !== null) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - } - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'type_dialog_failed', - message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` }); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // === Fill this cell: clipboard paste (trusted event) === - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(1500); - - // Check if paste was rejected (composite-type cell blocks text input until type is selected) - const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`); - if (!inputAfterPaste && text) { - // No type specified — can't fill this composite-type cell - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'type_required', - message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` }); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // Check for EDD autocomplete (indicates reference field) - const eddItems = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return null; - return [...edd.querySelectorAll('.eddText')] - .filter(el => el.offsetWidth > 0) - .map(el => el.innerText?.trim() || ''); - })()`); - - if (eddItems && eddItems.length > 0) { - // Reference field with autocomplete — click best match - // Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...) - // but keep standalone enum values like "Создать" (no space/colon after) - const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i)); - - if (realItems.length > 0) { - const tgt = normYo(text.toLowerCase()); - let pick = realItems.find(i => - normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt); - if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt)); - if (!pick) pick = realItems[0]; - - // Click EDD item via dispatchEvent (bypasses div.surface overlay) - const pickLower = pick.toLowerCase(); - await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd) return; - for (const el of edd.querySelectorAll('.eddText')) { - if (el.offsetWidth === 0) continue; - if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) { - const r = el.getBoundingClientRect(); - const opts = { bubbles:true, cancelable:true, - clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return; - } - } - })()`); - await waitForStable(); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, - method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') }); - } else { - // Only "Создать:" items — value not found in autocomplete - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'not_found', message: `No match for "${text}"` }); - } - - // Done? If so, don't Tab (avoids creating a new row after last cell) - if ([...pending.values()].every(p => p.filled)) break; - // Tab to move to next cell - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // No EDD — press Tab to commit the value - await page.keyboard.press('Tab'); - await page.waitForTimeout(1000); - - // Check for "нет в списке" cloud popup (reference field, value not found) - const notInList = await page.evaluate(`(() => { - for (const el of document.querySelectorAll('div')) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const s = getComputedStyle(el); - if (s.position !== 'absolute' && s.position !== 'fixed') continue; - if ((parseInt(s.zIndex) || 0) < 100) continue; - if ((el.innerText || '').includes('нет в списке')) return true; - } - return false; - })()`); - - if (notInList) { - // Cloud has "Показать все" link — try to open selection form via it - const clickedShowAll = await page.evaluate(`(() => { - for (const el of document.querySelectorAll('div')) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const s = getComputedStyle(el); - if (s.position !== 'absolute' && s.position !== 'fixed') continue; - if ((parseInt(s.zIndex) || 0) < 100) continue; - if (!(el.innerText || '').includes('нет в списке')) continue; - // Found the cloud — look for "Показать все" hyperlink inside - const links = [...el.querySelectorAll('a, span, div')] - .filter(e => e.offsetWidth > 0 && e.children.length === 0); - const showAll = links.find(e => { - const t = (e.innerText?.trim() || '').toLowerCase(); - return t === 'показать все' || t === 'show all'; - }); - if (showAll) { - const r = showAll.getBoundingClientRect(); - const opts = { bubbles:true, cancelable:true, - clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - showAll.dispatchEvent(new MouseEvent('mousedown', opts)); - showAll.dispatchEvent(new MouseEvent('mouseup', opts)); - showAll.dispatchEvent(new MouseEvent('click', opts)); - return true; - } - return false; - } - return false; - })()`); - - if (clickedShowAll) { - await waitForStable(formNum); - // Check if selection form opened - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - - if (selForm !== null) { - const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); - info.filled = true; - if (pickResult.ok) { - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }); - continue; - } - // Not found in selection form — fall through to clear + skip - results.push({ field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - } else { - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'not_found', message: `Value "${text}" not in list` }); - } - } else { - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'not_found', message: `Value "${text}" not in list` }); - } - - // 1C won't let us Tab away from an invalid ref value. - // Must clear the field first, then Tab to move on. - // Escape dismisses the cloud; Ctrl+A + Delete clears the text. - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Delete'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs) - const newForm = await helperDetectNewForm(formNum); - - if (newForm !== null) { - if (await isTypeDialog(newForm)) { - // Composite-type cell — need type to proceed - if (info.type) { - await pickFromTypeDialog(newForm, info.type); - await waitForStable(newForm); - // After type selection, the actual selection form should open - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null) { - // Primitive type — poll for calculator/calendar popup or settle on INPUT - let hasPopup = null; - for (let pw = 0; pw < 5; pw++) { - await page.waitForTimeout(200); - hasPopup = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return 'calculator'; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return 'calendar'; - return null; - })()`); - if (hasPopup) break; - } - if (hasPopup) { - await page.keyboard.press('Escape'); - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - const gone = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return false; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return false; - return true; - })()`); - if (gone) break; - } - } - const inInput = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (!inInput) { - const cellRect = await page.evaluate(`(() => { - const el = document.getElementById(${JSON.stringify(cell.id)}); - if (!el) return null; - const r = el.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; - })()`); - if (cellRect) { - await page.mouse.dblclick(cellRect.x, cellRect.y); - for (let fw = 0; fw < 4; fw++) { - await page.waitForTimeout(150); - const ok = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (ok) break; - } - } - } - await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); - await page.waitForTimeout(400); - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); - continue; - } - const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); - info.filled = true; - results.push(pickResult.ok - ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } - : { field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - continue; - } else { - // No type specified — close dialog, clear cell, report error - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Delete'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'type_required', - message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` }); - continue; - } - } - // Not a type dialog — normal selection form - const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum); - info.filled = true; - results.push(pickResult.ok - ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' } - : { field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - continue; - } - - // Plain field — value committed via Tab - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' }); - - // All done? - if ([...pending.values()].every(p => p.filled)) break; - // Tab already pressed — we're on next cell - } - - // Commit the new row: click on the grid header to exit edit mode. - // Clicking a different data row would re-enter edit mode on that row. - // Without this commit click, the row stays in "uncommitted add" state - // and a subsequent Escape (e.g. from closeForm) would cancel the entire row. - const commitTarget = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - if (head) { - const r = head.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - } - return null; - })()`); - if (commitTarget) { - await page.mouse.click(commitTarget.x, commitTarget.y); - await page.waitForTimeout(500); - } else { - // Fallback: Tab out of the last cell to commit the row - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - } - - // Dismiss any leftover error modals - const err = await checkForErrors(); - if (err?.modal) { - try { - const btn = await page.$('a.press.pressDefault'); - if (btn) { await btn.click(); await page.waitForTimeout(500); } - } catch { /* OK */ } - } - - const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key); - - // Retry unfilled checkbox fields via direct click (Tab skips checkbox cells) - if (notFilled.length > 0) { - const checkboxFields = {}; - for (const key of notFilled) { - const val = String(pending.get(key).value).toLowerCase().trim(); - if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) { - checkboxFields[key] = pending.get(key).value; - } - } - if (Object.keys(checkboxFields).length > 0) { - // Use row index: addedRowIdx (from add mode) or fallback to selected row - const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return -1; - const body = grid.querySelector('.gridBody'); - if (!body) return -1; - const lines = [...body.querySelectorAll('.gridLine')]; - const sel = lines.findIndex(l => l.classList.contains('selected')); - return sel >= 0 ? sel : lines.length - 1; - })()`) - ); - if (currentRow >= 0) { - const more = await fillTableRow(checkboxFields, { row: currentRow, table }); - if (Array.isArray(more)) { - results.push(...more); - } else if (more?.filled) { - results.push(...more.filled); - } - for (const key of Object.keys(checkboxFields)) { - const idx = notFilled.indexOf(key); - if (idx >= 0) notFilled.splice(idx, 1); - } - } - } - } - - const formData = await getFormState(); - const result = { filled: results }; - if (notFilled.length > 0) result.notFilled = notFilled; - result.form = formData; - return result; - - } catch (e) { - if (e.message.startsWith('fillTableRow:')) throw e; - throw new Error(`fillTableRow: ${e.message}`); - } -} - -/** - * Delete a row from the current table part. - * Single click to select the row, then Delete key to remove it. - * - * @param {number} row - 0-based row index to delete - * @param {Object} [options] - * @param {string} [options.tab] - Switch to this form tab before operating - * @returns {{ deleted, rowsBefore, rowsAfter, form }} - */ -export async function deleteTableRow(row, { tab, table } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('deleteTableRow: no form found'); - - // Pre-resolve grid when table is specified - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - - // 1. Switch tab if requested - if (tab) { - await clickElement(tab); - await page.waitForTimeout(500); - } - - // 2. Find the target row and click to select it - const cellCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return { error: 'no_grid' }; - const body = grid.querySelector('.gridBody'); - if (!body) return { error: 'no_grid_body' }; - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; - const line = rows[${row}]; - // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes - const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - // Skip first column (row number / checkbox) — pick second visible box - const box = boxes.length > 1 ? boxes[1] : boxes[0]; - if (!box) return { error: 'no_cell' }; - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; - })()`); - - if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); - - const rowsBefore = cellCoords.total; - - // Single click to select the row - await page.mouse.click(cellCoords.x, cellCoords.y); - await page.waitForTimeout(300); - - // 3. Press Delete to remove the row - await page.keyboard.press('Delete'); - await waitForStable(); - - // 4. Count rows after deletion - const rowsAfter = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return 0; - const body = grid.querySelector('.gridBody'); - return body ? body.querySelectorAll('.gridLine').length : 0; - })()`); - - const formData = await getFormState(); - return { deleted: row, rowsBefore, rowsAfter, form: formData }; -} +// ============================================================ +// fillTableRow / deleteTableRow — extracted to table/{row-fill,grid}.mjs +// ============================================================ +export { fillTableRow } from './table/row-fill.mjs'; +export { deleteTableRow } from './table/grid.mjs'; // ============================================================ // List filters — extracted to table/filter.mjs diff --git a/.claude/skills/web-test/scripts/table/grid.mjs b/.claude/skills/web-test/scripts/table/grid.mjs index 1cdb5dd1..5b77e68f 100644 --- a/.claude/skills/web-test/scripts/table/grid.mjs +++ b/.claude/skills/web-test/scripts/table/grid.mjs @@ -7,6 +7,11 @@ import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; +import { dismissPendingErrors } from '../core/errors.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { clickElement } from '../core/click.mjs'; +// getFormState lives in browser.mjs. +import { getFormState } from '../browser.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { @@ -21,3 +26,79 @@ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { } return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); } + +/** + * Delete a row from the current table part. + * Single click to select the row, then Delete key to remove it. + * + * @param {number} row - 0-based row index to delete + * @param {Object} [options] + * @param {string} [options.tab] - Switch to this form tab before operating + * @returns {{ deleted, rowsBefore, rowsAfter, form }} + */ +export async function deleteTableRow(row, { tab, table } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('deleteTableRow: no form found'); + + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + + // 1. Switch tab if requested + if (tab) { + await clickElement(tab); + await page.waitForTimeout(500); + } + + // 2. Find the target row and click to select it + const cellCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return { error: 'no_grid' }; + const body = grid.querySelector('.gridBody'); + if (!body) return { error: 'no_grid_body' }; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; + const line = rows[${row}]; + // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + // Skip first column (row number / checkbox) — pick second visible box + const box = boxes.length > 1 ? boxes[1] : boxes[0]; + if (!box) return { error: 'no_cell' }; + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; + })()`); + + if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); + + const rowsBefore = cellCoords.total; + + // Single click to select the row + await page.mouse.click(cellCoords.x, cellCoords.y); + await page.waitForTimeout(300); + + // 3. Press Delete to remove the row + await page.keyboard.press('Delete'); + await waitForStable(); + + // 4. Count rows after deletion + const rowsAfter = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return 0; + const body = grid.querySelector('.gridBody'); + return body ? body.querySelectorAll('.gridLine').length : 0; + })()`); + + const formData = await getFormState(); + return { deleted: row, rowsBefore, rowsAfter, form: formData }; +} diff --git a/.claude/skills/web-test/scripts/table/row-fill.mjs b/.claude/skills/web-test/scripts/table/row-fill.mjs new file mode 100644 index 00000000..57361a28 --- /dev/null +++ b/.claude/skills/web-test/scripts/table/row-fill.mjs @@ -0,0 +1,1235 @@ +// web-test table/row-fill v1.16 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, ensureConnected, normYo, highlightMode, ACTION_WAIT, +} from '../core/state.mjs'; +import { + detectFormScript, resolveGridScript, readTableScript, +} from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; +import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { + safeClick, findFieldInputId, + detectNewForm as helperDetectNewForm, +} from '../core/helpers.mjs'; +import { clickElement } from '../core/click.mjs'; +import { + pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, + fillReferenceField, selectValue, +} from '../forms/select-value.mjs'; +// pasteText + getFormState still in browser.mjs (cycle). +import { pasteText, getFormState } from '../browser.mjs'; + +/** + * Fill cells in the current table row via Tab navigation. + * Grid cells are only accessible sequentially (Tab) — no random access. + * + * After "Добавить", 1C enters inline edit mode on the first cell. + * All inputs in the row are created hidden (offsetWidth=0); only the active one is visible. + * Tab moves through cells in a fixed order determined by the form configuration. + * + * @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура") + * @param {Object} [options] + * @param {string} [options.tab] - Switch to this form tab before operating + * @param {boolean} [options.add] - Click "Добавить" to create a new row first + * @returns {{ filled[], notFilled[]?, form }} + */ +export async function fillTableRow(fields, { tab, add, row, table } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('fillTableRow: no form found'); + + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + + try { + // 1. Switch tab if requested + if (tab) { + await clickElement(tab); + } + + // 2. Add new row if requested + let addedRowIdx = -1; + if (add) { + // Count rows before add — new row will be appended at this index + addedRowIdx = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + const body = grid?.querySelector('.gridBody'); + return body ? body.querySelectorAll('.gridLine').length : 0; + })()`); + await clickElement('Добавить', { table }); + // Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait + for (let aw = 0; aw < 6; aw++) { + await page.waitForTimeout(150); + const ready = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; + let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } + return false; + })()`); + if (ready) break; + } + } + + // 2b. Enter edit mode on existing row by dblclick + if (row != null) { + // Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right + const sortedKeys = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + if (!head) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); + const ci = parseInt(box.getAttribute('colindex') || '-1'); + if (t) cols.push({ text: t, colindex: ci }); + }); + const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))}; + const mapped = keys.map(k => { + const exact = cols.find(c => c.text === k); + if (exact) return { key: k, colindex: exact.colindex }; + const inc = cols.find(c => c.text.includes(k) || k.includes(c.text)); + return { key: k, colindex: inc ? inc.colindex : 999 }; + }); + mapped.sort((a, b) => a.colindex - b.colindex); + return mapped.map(m => m.key); + })()`); + if (sortedKeys) { + // Rebuild fields in sorted order + const sortedFields = {}; + for (const kl of sortedKeys) { + const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl); + if (origKey) sortedFields[origKey] = fields[origKey]; + } + // Add any keys not matched in header (preserve original order for those) + for (const k of Object.keys(fields)) { + if (!(k in sortedFields)) sortedFields[k] = fields[k]; + } + fields = sortedFields; + } + + const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); + const cellCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return { error: 'no_grid' }; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return { error: 'no_grid_body' }; + + // Read column headers to find target colindex + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + const ci = box.getAttribute('colindex'); + cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + + const keys = ${fieldKeys}; + let targetColindex = null; + for (const key of keys) { + const exact = cols.find(c => c.text === key); + if (exact) { targetColindex = exact.colindex; break; } + const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); + if (inc) { targetColindex = inc.colindex; break; } + } + + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; + const line = rows[${row}]; + + // Find body cell by colindex (reliable across merged headers) + let box = null; + if (targetColindex != null) { + box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); + } + // Fallback: second visible box (skip checkbox/N column) + if (!box) { + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + box = boxes.length > 1 ? boxes[1] : boxes[0]; + } + if (!box) return { error: 'no_cell' }; + // Scroll into view if off-screen + box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; + })()`); + + if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); + + // Skip if cell already contains the desired value (single-field optimization) + const firstKey0 = Object.keys(fields)[0]; + const rawFirstVal = fields[firstKey0]; + const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === '' + ? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal)); + let firstFieldSkipped = false; + if (cellCoords.currentText && firstVal0 && + cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) { + firstFieldSkipped = true; + if (Object.keys(fields).length === 1) { + return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }]; + } + } + + // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse). + // Then escalate: dblclick → F4 if needed. + await page.mouse.click(cellCoords.x, cellCoords.y); + + // Clear cell via Shift+F4 if value is empty + if (firstVal0 === '') { + await page.waitForTimeout(500); + // Check if click opened a selection form — close it first + let openedForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (openedForm !== null) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } else { + // No form opened — need to enter edit mode first (dblclick), then close any form that opens + await page.mouse.dblclick(cellCoords.x, cellCoords.y); + await page.waitForTimeout(500); + openedForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (openedForm !== null) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + } + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }]; + // If more fields remain, process them on the same row + const remaining = { ...fields }; + delete remaining[firstKey0]; + if (Object.keys(remaining).length > 0) { + const more = await fillTableRow(remaining, { row, table }); + if (Array.isArray(more)) results.push(...more); + else if (more?.filled) results.push(...more.filled); + } + const formData = await getFormState(); + return { filled: results, form: formData }; + } + + // Check if clicked cell is a checkbox (toggle-on-click, no edit mode) + const checkboxInfo = await page.evaluate(`(() => { + const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y}); + const cell = el?.closest('.gridBox'); + if (!cell) return null; + const chk = cell.querySelector('.checkbox'); + if (!chk) return null; + const r = chk.getBoundingClientRect(); + return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; + })()`); + if (checkboxInfo !== null) { + // Checkbox cell found — click directly on the checkbox icon (not cell center) + const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim()); + if (checkboxInfo.checked !== desired) { + await page.mouse.click(checkboxInfo.x, checkboxInfo.y); + await page.waitForTimeout(300); + } + const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }]; + await waitForStable(formNum); + // If more fields remain, process them on the same row + const remaining = { ...fields }; + delete remaining[firstKey0]; + if (Object.keys(remaining).length > 0) { + const more = await fillTableRow(remaining, { row, table }); + results.push(...more); + } + return results; + } + + let inEdit = false; + let directEditForm = null; + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + // Click didn't enter edit — try dblclick (works for flat grids) + if (!inEdit && directEditForm === null) { + await page.mouse.dblclick(cellCoords.x, cellCoords.y); + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + } + // Still nothing — try F4 (opens selection for direct-edit cells) + if (!inEdit && directEditForm === null) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + } + + // When click entered INPUT mode but no selection form yet — try F4 only for tree grids + // (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop) + if (inEdit && directEditForm === null) { + const isTreeGrid = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + return grid ? !!grid.querySelector('.gridBoxTree') : false; + })()`); + if (isTreeGrid) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + // If F4 didn't open a selection form, fall through to Tab loop + } + } + + // Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing). + // Handle each field by picking from selection form, then dblclick next cell. + if (directEditForm !== null) { + const pending = new Map(); + for (const [key, val] of Object.entries(fields)) { + if (val && typeof val === 'object' && 'value' in val) { + pending.set(key, { value: String(val.value), type: val.type || null, filled: false }); + } else { + pending.set(key, { value: String(val), type: null, filled: false }); + } + } + const results = []; + + // Helper: handle type dialog + pick from selection form + async function directEditPick(openedForm, key, info) { + let selForm = openedForm; + // Check if opened form is a type selection dialog (composite type field) + if (await isTypeDialog(selForm)) { + if (info.type) { + await pickFromTypeDialog(selForm, info.type); + await waitForStable(selForm); + // After type selection, detect the actual selection form + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; + } + } else { + // No type specified — close type dialog and report error + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; + } + } + const pr = await pickFromSelectionForm(selForm, key, info.value, formNum); + return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message }; + } + + // First field: selection form is already open from the dblclick above + const firstKey = Object.keys(fields)[0]; + const firstInfo = pending.get(firstKey); + if (firstFieldSkipped) { + firstInfo.filled = true; + results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText }); + // Close the selection form that opened from the click + await page.keyboard.press('Escape'); + await waitForStable(formNum); + } else { + const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); + firstInfo.filled = true; + results.push(pickResult); + } + + // Remaining fields: dblclick on each column cell individually + for (const [key, info] of pending) { + if (info.filled) continue; + // Find column for this key and dblclick on it + const nextCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + const ci = box.getAttribute('colindex'); + cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + const kl = ${JSON.stringify(key.toLowerCase())}; + const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); + let targetColindex = null; + const exact = cols.find(c => c.text === kl); + if (exact) targetColindex = exact.colindex; + else { + const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) + || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); + if (inc) targetColindex = inc.colindex; + } + if (targetColindex == null) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return null; + const line = rows[${row}]; + const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); + if (!box) return null; + box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; + })()`); + if (!nextCoords) { + info.filled = true; + results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); + continue; + } + // Skip if cell already contains the desired value + if (nextCoords.currentText && info.value && + nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) { + info.filled = true; + results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText }); + continue; + } + await page.mouse.dblclick(nextCoords.x, nextCoords.y); + await page.waitForTimeout(300); + // Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator + const inInputAfterDblclick = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; + let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } + return false; + })()`); + // Also check if a selection form already appeared + let selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null && inInputAfterDblclick) { + // Plain text/numeric field — fill via clipboard paste + await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); + await page.waitForTimeout(400); + // Dismiss EDD autocomplete if it appeared + const hasEdd = await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + return edd && edd.offsetWidth > 0; + })()`); + if (hasEdd) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + } + info.filled = true; + results.push({ field: key, ok: true, method: 'paste' }); + continue; + } + // Poll for selection form (with F4 fallback if dblclick didn't open it) + if (selForm === null) { + for (let attempt = 0; attempt < 2 && selForm === null; attempt++) { + if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback + for (let sw = 0; sw < 6; sw++) { + await page.waitForTimeout(200); + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm !== null) break; + } + } + } + if (selForm === null) { + info.filled = true; + results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); + continue; + } + const pr = await directEditPick(selForm, key, info); + info.filled = true; + results.push(pr); + } + // Commit the edit: click on a different row (Escape cancels in tree grids). + // Find the first visible row that is NOT the edited row and click it. + const commitCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + const otherIdx = ${row} === 0 ? 1 : 0; + const other = rows[otherIdx]; + if (!other) return null; + const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; + if (!box) return null; + const r = box.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (commitCoords) { + await page.mouse.click(commitCoords.x, commitCoords.y); + } else { + await page.keyboard.press('Escape'); + } + await waitForStable(formNum); + return results; + } + + if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); + } else { + // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent) + const editCheck = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; + let node = f; + while (node) { + if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; + node = node.parentElement; + } + return { inEdit: false, hint: 'input not inside grid' }; + })()`); + + if (!editCheck.inEdit) { + throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); + } + } + + // 4. Prepare pending fields for fuzzy matching + const pending = new Map(); + for (const [key, val] of Object.entries(fields)) { + if (val === null || val === undefined || val === '') { + pending.set(key, { value: '', type: null, filled: false }); + } else if (val && typeof val === 'object' && 'value' in val) { + const innerVal = val.value; + pending.set(key, { + value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal), + type: val.type || null, filled: false + }); + } else { + pending.set(key, { value: String(val), type: null, filled: false }); + } + } + + const results = []; + const MAX_ITER = 40; + let prevCellId = null; + let nonInputCount = 0; + let firstCellId = null; + + for (let iter = 0; iter < MAX_ITER; iter++) { + // Read focused element (INPUT or TEXTAREA inside grid = editable cell) + const cell = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f) return { tag: 'none' }; + if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { + const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })(); + if (inGrid) { + let headerText = ''; + let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement; + if (grid) { + const fr = f.getBoundingClientRect(); + const head = grid.querySelector('.gridHead'); + const hl = head?.querySelector('.gridLine') || head; + if (hl) for (const h of hl.children) { + if (h.offsetWidth === 0) continue; + const hr = h.getBoundingClientRect(); + if (fr.x >= hr.x && fr.x < hr.x + hr.width) { + const t = h.querySelector('.gridBoxText'); + headerText = (t || h).innerText?.trim() || ''; + break; + } + } + } + return { + tag: 'INPUT', id: f.id, + fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''), + headerText + }; + } + } + return { tag: f.tagName || 'none' }; + })()`); + + if (cell.tag !== 'INPUT' || !cell.fullName) { + // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) + nonInputCount++; + // If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows + const onlyCheckboxLeft = [...pending.values()].every(p => p.filled || + ['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim())); + if (nonInputCount > 3 || onlyCheckboxLeft) break; + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + continue; + } + nonInputCount = 0; + + // Track first cell to detect wrap-around (Tab looped back to row start) + if (firstCellId === null) firstCellId = cell.id; + else if (cell.id === firstCellId) break; // wrapped around — all cells visited + + // Stuck detection: same cell twice in a row → force Tab + if (cell.id === prevCellId) { + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + prevCellId = null; + continue; + } + prevCellId = cell.id; + + // Fuzzy match cell name to user field: exact → suffix → includes → no-space includes + const cellLower = cell.fullName.toLowerCase(); + let matchedKey = null; + for (const [key, info] of pending) { + if (info.filled) continue; + const kl = key.toLowerCase(); + if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) { + matchedKey = key; + break; + } + // CamelCase cell names have no spaces/dashes — try matching without spaces and dashes + const klNoSpace = kl.replace(/[\s\-]+/g, ''); + if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) { + matchedKey = key; + break; + } + } + + // Fallback: match by column header text (handles metadata typos in cell id) + if (!matchedKey && cell.headerText) { + const htLower = cell.headerText.toLowerCase(); + for (const [key, info] of pending) { + if (info.filled) continue; + const kl = key.toLowerCase(); + if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) { + matchedKey = key; + break; + } + } + } + + if (!matchedKey) { + // Skip this cell + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + continue; + } + + const info = pending.get(matchedKey); + const text = info.value; + + // Clear cell if value is empty (Shift+F4 = native 1C clear) + if (text === '') { + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' }); + if ([...pending.values()].every(p => p.filled)) break; + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // If user specified a type, always clear and use type selection flow + if (info.type) { + await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type + await page.waitForTimeout(300); + await page.keyboard.press('F4'); + // Poll for type dialog form to appear + let typeForm = null; + for (let tw = 0; tw < 6; tw++) { + await page.waitForTimeout(200); + typeForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (typeForm !== null) break; + } + if (typeForm !== null && await isTypeDialog(typeForm)) { + await pickFromTypeDialog(typeForm, info.type); + await waitForStable(typeForm); + // After type selection, check if a selection form opened (ref types) + const selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + // Primitive type — poll for calculator/calendar popup or settle on INPUT + let hasPopup = null; + for (let pw = 0; pw < 5; pw++) { + await page.waitForTimeout(200); + hasPopup = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return 'calculator'; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return 'calendar'; + return null; + })()`); + if (hasPopup) break; + } + if (hasPopup) { + await page.keyboard.press('Escape'); + // Poll for popup to disappear + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + const gone = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return false; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return false; + return true; + })()`); + if (gone) break; + } + } + // Ensure we are in an editable INPUT for this cell + const inInput = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (!inInput) { + const cellRect = await page.evaluate(`(() => { + const el = document.getElementById(${JSON.stringify(cell.id)}); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`); + if (cellRect) { + await page.mouse.dblclick(cellRect.x, cellRect.y); + // Poll for INPUT focus + for (let fw = 0; fw < 4; fw++) { + await page.waitForTimeout(150); + const ok = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (ok) break; + } + } + } + await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); + await page.waitForTimeout(400); + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); + continue; + } + const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); + info.filled = true; + results.push(pickResult.ok + ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } + : { field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + continue; + } + // F4 opened something but not a type dialog — close and report + if (typeForm !== null) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'type_dialog_failed', + message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` }); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // === Fill this cell: clipboard paste (trusted event) === + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(1500); + + // Check if paste was rejected (composite-type cell blocks text input until type is selected) + const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`); + if (!inputAfterPaste && text) { + // No type specified — can't fill this composite-type cell + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'type_required', + message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` }); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // Check for EDD autocomplete (indicates reference field) + const eddItems = await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return null; + return [...edd.querySelectorAll('.eddText')] + .filter(el => el.offsetWidth > 0) + .map(el => el.innerText?.trim() || ''); + })()`); + + if (eddItems && eddItems.length > 0) { + // Reference field with autocomplete — click best match + // Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...) + // but keep standalone enum values like "Создать" (no space/colon after) + const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i)); + + if (realItems.length > 0) { + const tgt = normYo(text.toLowerCase()); + let pick = realItems.find(i => + normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt); + if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt)); + if (!pick) pick = realItems[0]; + + // Click EDD item via dispatchEvent (bypasses div.surface overlay) + const pickLower = pick.toLowerCase(); + await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd) return; + for (const el of edd.querySelectorAll('.eddText')) { + if (el.offsetWidth === 0) continue; + if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) { + const r = el.getBoundingClientRect(); + const opts = { bubbles:true, cancelable:true, + clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return; + } + } + })()`); + await waitForStable(); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, + method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') }); + } else { + // Only "Создать:" items — value not found in autocomplete + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'not_found', message: `No match for "${text}"` }); + } + + // Done? If so, don't Tab (avoids creating a new row after last cell) + if ([...pending.values()].every(p => p.filled)) break; + // Tab to move to next cell + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // No EDD — press Tab to commit the value + await page.keyboard.press('Tab'); + await page.waitForTimeout(1000); + + // Check for "нет в списке" cloud popup (reference field, value not found) + const notInList = await page.evaluate(`(() => { + for (const el of document.querySelectorAll('div')) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const s = getComputedStyle(el); + if (s.position !== 'absolute' && s.position !== 'fixed') continue; + if ((parseInt(s.zIndex) || 0) < 100) continue; + if ((el.innerText || '').includes('нет в списке')) return true; + } + return false; + })()`); + + if (notInList) { + // Cloud has "Показать все" link — try to open selection form via it + const clickedShowAll = await page.evaluate(`(() => { + for (const el of document.querySelectorAll('div')) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const s = getComputedStyle(el); + if (s.position !== 'absolute' && s.position !== 'fixed') continue; + if ((parseInt(s.zIndex) || 0) < 100) continue; + if (!(el.innerText || '').includes('нет в списке')) continue; + // Found the cloud — look for "Показать все" hyperlink inside + const links = [...el.querySelectorAll('a, span, div')] + .filter(e => e.offsetWidth > 0 && e.children.length === 0); + const showAll = links.find(e => { + const t = (e.innerText?.trim() || '').toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + if (showAll) { + const r = showAll.getBoundingClientRect(); + const opts = { bubbles:true, cancelable:true, + clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + showAll.dispatchEvent(new MouseEvent('mousedown', opts)); + showAll.dispatchEvent(new MouseEvent('mouseup', opts)); + showAll.dispatchEvent(new MouseEvent('click', opts)); + return true; + } + return false; + } + return false; + })()`); + + if (clickedShowAll) { + await waitForStable(formNum); + // Check if selection form opened + const selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { + if (el.offsetWidth === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + + if (selForm !== null) { + const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); + info.filled = true; + if (pickResult.ok) { + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }); + continue; + } + // Not found in selection form — fall through to clear + skip + results.push({ field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + } else { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'not_found', message: `Value "${text}" not in list` }); + } + } else { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'not_found', message: `Value "${text}" not in list` }); + } + + // 1C won't let us Tab away from an invalid ref value. + // Must clear the field first, then Tab to move on. + // Escape dismisses the cloud; Ctrl+A + Delete clears the text. + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Delete'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs) + const newForm = await helperDetectNewForm(formNum); + + if (newForm !== null) { + if (await isTypeDialog(newForm)) { + // Composite-type cell — need type to proceed + if (info.type) { + await pickFromTypeDialog(newForm, info.type); + await waitForStable(newForm); + // After type selection, the actual selection form should open + const selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + // Primitive type — poll for calculator/calendar popup or settle on INPUT + let hasPopup = null; + for (let pw = 0; pw < 5; pw++) { + await page.waitForTimeout(200); + hasPopup = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return 'calculator'; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return 'calendar'; + return null; + })()`); + if (hasPopup) break; + } + if (hasPopup) { + await page.keyboard.press('Escape'); + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + const gone = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return false; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return false; + return true; + })()`); + if (gone) break; + } + } + const inInput = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (!inInput) { + const cellRect = await page.evaluate(`(() => { + const el = document.getElementById(${JSON.stringify(cell.id)}); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`); + if (cellRect) { + await page.mouse.dblclick(cellRect.x, cellRect.y); + for (let fw = 0; fw < 4; fw++) { + await page.waitForTimeout(150); + const ok = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (ok) break; + } + } + } + await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); + await page.waitForTimeout(400); + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); + continue; + } + const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); + info.filled = true; + results.push(pickResult.ok + ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } + : { field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + continue; + } else { + // No type specified — close dialog, clear cell, report error + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Delete'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'type_required', + message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` }); + continue; + } + } + // Not a type dialog — normal selection form + const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum); + info.filled = true; + results.push(pickResult.ok + ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' } + : { field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + continue; + } + + // Plain field — value committed via Tab + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' }); + + // All done? + if ([...pending.values()].every(p => p.filled)) break; + // Tab already pressed — we're on next cell + } + + // Commit the new row: click on the grid header to exit edit mode. + // Clicking a different data row would re-enter edit mode on that row. + // Without this commit click, the row stays in "uncommitted add" state + // and a subsequent Escape (e.g. from closeForm) would cancel the entire row. + const commitTarget = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + if (head) { + const r = head.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + return null; + })()`); + if (commitTarget) { + await page.mouse.click(commitTarget.x, commitTarget.y); + await page.waitForTimeout(500); + } else { + // Fallback: Tab out of the last cell to commit the row + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + } + + // Dismiss any leftover error modals + const err = await checkForErrors(); + if (err?.modal) { + try { + const btn = await page.$('a.press.pressDefault'); + if (btn) { await btn.click(); await page.waitForTimeout(500); } + } catch { /* OK */ } + } + + const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key); + + // Retry unfilled checkbox fields via direct click (Tab skips checkbox cells) + if (notFilled.length > 0) { + const checkboxFields = {}; + for (const key of notFilled) { + const val = String(pending.get(key).value).toLowerCase().trim(); + if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) { + checkboxFields[key] = pending.get(key).value; + } + } + if (Object.keys(checkboxFields).length > 0) { + // Use row index: addedRowIdx (from add mode) or fallback to selected row + const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return -1; + const body = grid.querySelector('.gridBody'); + if (!body) return -1; + const lines = [...body.querySelectorAll('.gridLine')]; + const sel = lines.findIndex(l => l.classList.contains('selected')); + return sel >= 0 ? sel : lines.length - 1; + })()`) + ); + if (currentRow >= 0) { + const more = await fillTableRow(checkboxFields, { row: currentRow, table }); + if (Array.isArray(more)) { + results.push(...more); + } else if (more?.filled) { + results.push(...more.filled); + } + for (const key of Object.keys(checkboxFields)) { + const idx = notFilled.indexOf(key); + if (idx >= 0) notFilled.splice(idx, 1); + } + } + } + } + + const formData = await getFormState(); + const result = { filled: results }; + if (notFilled.length > 0) result.notFilled = notFilled; + result.form = formData; + return result; + + } catch (e) { + if (e.message.startsWith('fillTableRow:')) throw e; + throw new Error(`fillTableRow: ${e.message}`); + } +}