diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 31ddb0a5..9f32ebb9 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -181,581 +181,11 @@ export async function getFormState() { return state; } -/** Read structured table data with pagination. Returns columns, rows, total count. */ -export async function readTable({ maxRows = 20, offset = 0, table } = {}) { - ensureConnected(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('readTable: no form found'); - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); -} +// ============================================================ +// Table reading + SpreadsheetDocument — extracted to table/spreadsheet.mjs +// ============================================================ +export { readTable, readSpreadsheet } from './table/spreadsheet.mjs'; -// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- - -/** - * Scan spreadsheet iframes for the current form and collect all cells. - * Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> } - * where frameIndex is the Playwright frames[] index (1-based, 0 = main). - */ -async function scanSpreadsheetCells(formNum) { - const prefix = `form${formNum ?? 0}_`; - const iframeHandles = await page.$$('iframe'); - - const allCells = new Map(); - const frameMap = new Map(); // key 'r_c' → Playwright Frame object - - for (const handle of iframeHandles) { - const ok = await handle.evaluate((f, pfx) => { - if (f.offsetWidth < 100) return false; - let el = f.parentElement; - for (let d = 0; el && d < 30; d++, el = el.parentElement) { - if (el.id && el.id.startsWith(pfx)) return true; - } - return false; - }, prefix); - if (!ok) continue; - - const frame = await handle.contentFrame(); - if (!frame) continue; - - try { - const cells = await frame.evaluate(`(() => { - const cells = []; - document.querySelectorAll('div[x]').forEach(d => { - const span = d.querySelector('span'); - const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || ''; - if (!text) return; - const rowDiv = d.parentElement; - const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null; - const col = d.getAttribute('x'); - if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text }); - }); - return cells; - })()`); - for (const cell of cells) { - const key = `${cell.r}_${cell.c}`; - if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) { - allCells.set(key, cell); - frameMap.set(key, frame); - } - } - } catch { /* skip inaccessible frames */ } - } - return { allCells, frameMap }; -} - -/** - * Build structured mapping from raw cells: headers, column map, data/totals row indices. - * Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap } - * or null if header detection fails. - */ -function buildSpreadsheetMapping(allCells) { - const rowMap = new Map(); - let maxCol = 0; - for (const cell of allCells.values()) { - if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); - rowMap.get(cell.r).set(cell.c, cell.t); - if (cell.c > maxCol) maxCol = cell.c; - } - - const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); - const rows = sortedRows.map(r => { - const cm = rowMap.get(r); - const arr = []; - for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); - return arr; - }); - - // Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78" - // Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification. - const isNumericVal = (c) => { - if (!c || !/\d/.test(c)) return false; - const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, ''); - return /^\d[\d,]*$/.test(s); - }; - // Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus). - // Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51". - const isDataNumericVal = (c) => { - if (!isNumericVal(c)) return false; - return /[\s\u00a0,]/.test(c) || /^-/.test(c); - }; - const hasNumber = (row) => row.some(c => isNumericVal(c)); - const nonEmpty = (row) => row.filter(c => c !== '').length; - - // Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx. - // Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor). - const buildRichMapping = (detailIdx, firstDataIdx) => { - let groupIdx = -1; - if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1; - - const detailRow = rows[detailIdx]; - const groupRow = groupIdx >= 0 ? rows[groupIdx] : null; - - // Detect optional third header level above group row (bounds carry-forward) - let superRow = null; - if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) { - superRow = rows[groupIdx - 1]; - } - - // Build column names (group + detail merge) - const groupFilled = new Array(maxCol + 1).fill(''); - if (groupRow) { - let cur = ''; - for (let c = 0; c <= maxCol; c++) { - if (groupRow[c]) { - cur = groupRow[c]; - } else if (superRow && superRow[c]) { - // New top-level header starts here — stop carry-forward - cur = ''; - } - groupFilled[c] = cur; - } - } - - const detailCounts = {}; - for (let c = 0; c <= maxCol; c++) { - const n = detailRow[c]; - if (n) detailCounts[n] = (detailCounts[n] || 0) + 1; - } - - // Detect DCS column codes (К1, К2, ...) — always prefix with group when present - const detailNonEmpty = detailRow.filter(c => c); - const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c)); - - const colNames = []; - for (let c = 0; c <= maxCol; c++) { - const detail = detailRow[c]; - const group = groupFilled[c]; - const sup = superRow ? superRow[c] : ''; - if (detail) { - // Prefer group prefix; fall back to superRow for DCS code columns without sub-group - const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : ''); - const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === '')); - colNames.push(needPrefix ? `${prefix} / ${detail}` : detail); - } else if (group) { - colNames.push(group); - } else if (sup) { - colNames.push(sup); - } else { - colNames.push(null); - } - } - - const colMap = new Map(); - for (let c = 0; c < colNames.length; c++) { - if (colNames[c]) colMap.set(colNames[c], c); - } - - // Classify data rows: separate data indices and totals index - const dataRowIndices = []; - let totalsRowIdx = -1; - for (let i = firstDataIdx; i < rows.length; i++) { - if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; - const first = rows[i][0]?.trim().toLowerCase(); - if (first === 'итого' || first === 'всего') { - totalsRowIdx = i; - } else { - dataRowIndices.push(i); - } - } - - const superRowIdx = superRow ? groupIdx - 1 : -1; - - return { - rows, sortedRows, maxCol, colNames, colMap, - headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx, - dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx, - rowMap, hasNumber, nonEmpty, - }; - }; - - // --- Level 1: DCS-code row anchor --- - // ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker. - // Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб"). - for (let i = 0; i < rows.length; i++) { - const detailNonEmpty = rows[i].filter(c => c); - if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) { - // Find first non-empty row after the К-codes row as data start - let firstDataIdx = rows.length; - for (let j = i + 1; j < rows.length; j++) { - if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; } - } - return buildRichMapping(i, firstDataIdx); - } - } - - // --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) --- - let firstDataIdx = rows.length; - for (let i = 0; i < rows.length; i++) { - if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; } - } - if (firstDataIdx === rows.length) { - for (let i = 0; i < rows.length; i++) { - if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; } - } - } - - if (firstDataIdx < rows.length) { - let detailIdx = -1; - for (let i = firstDataIdx - 1; i >= 0; i--) { - if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; } - } - if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx); - } - - // --- Level 3: single-row header fallback (text-only data, query console) --- - // First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition. - let headerIdx = -1; - for (let i = 0; i < rows.length; i++) { - if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; } - } - // Single-column tables: accept nonEmpty >= 1 - if (headerIdx === -1 && maxCol === 0) { - for (let i = 0; i < rows.length; i++) { - if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; } - } - } - if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total } - - const detailRow = rows[headerIdx]; - const colNames = []; - for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null); - const colMap = new Map(); - for (let c = 0; c < colNames.length; c++) { - if (colNames[c]) colMap.set(colNames[c], c); - } - - const dataRowIndices = []; - let totalsRowIdx = -1; - for (let i = headerIdx + 1; i < rows.length; i++) { - if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; - const first = rows[i][0]?.trim().toLowerCase(); - if (first === 'итого' || first === 'всего') { - totalsRowIdx = i; - } else { - dataRowIndices.push(i); - } - } - - return { - rows, sortedRows, maxCol, colNames, colMap, - headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1, - dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx, - rowMap, hasNumber, nonEmpty, - }; -} - -/** - * Scroll SpreadsheetDocument to make a cell visible using arrow keys. - * Uses native platform scroll — keeps headers, data, and scrollbar synchronized. - * - * How it works: - * 1. Check target cell visibility via Playwright boundingBox (page-level coords). - * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay. - * This is the same native click that clickSpreadsheetCell uses — it gives keyboard - * focus to the spreadsheet and keeps headers/data/scrollbar in sync. - * (frame.locator().click() bypasses overlay → desyncs frozen headers; - * page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.) - * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport. - * - * @param {Frame} frame - Playwright Frame containing the spreadsheet cells - * @param {number} physRow - physical row (y attribute) in the frame - * @param {number} physCol - physical column (x attribute) in the frame - * @param {Locator} cellLoc - Playwright locator for the target cell (from caller) - */ -async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { - const pageVw = await page.evaluate('window.innerWidth'); - // Get iframe bounds — the actual visible region on page. - // The iframe may extend behind the section panel on the left, so cells with - // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel. - const frameElm = await frame.frameElement(); - const frameBox = await frameElm.boundingBox(); - const visLeft = frameBox ? frameBox.x : 0; - const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw; - - const getBox = async () => { - try { return await cellLoc.boundingBox({ timeout: 500 }); } - catch { return null; } - }; - const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight; - - let box = await getBox(); - if (!box) return; // cell not in DOM - if (isFullyVisible(box)) return; - - const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft'; - - // Find a fully-visible cell to click for focus. - // Prefer cells in the target row (scrollable area), fall back to any row. - const targetRowSel = `div[y="${physRow}"] div[x]`; - const anyRowSel = 'div[x]'; - let focusClicked = false; - for (const sel of [targetRowSel, anyRowSel]) { - const locs = frame.locator(sel); - const count = await locs.count(); - const candidates = []; - for (let ci = 0; ci < count; ci++) { - const b = await locs.nth(ci).boundingBox(); - if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) { - candidates.push({ ci, box: b }); - } - } - if (candidates.length === 0) continue; - candidates.sort((a, b) => a.box.x - b.box.x); - // ArrowRight → rightmost fully-visible (each press scrolls right immediately) - // ArrowLeft → leftmost fully-visible (each press scrolls left immediately) - const pick = direction === 'ArrowRight' - ? candidates[candidates.length - 1] - : candidates[0]; - // Native click through overlay — gives keyboard focus + no header desync. - await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2); - await page.waitForTimeout(100); - focusClicked = true; - break; - } - if (!focusClicked) return; // no visible cells — can't scroll - - // Arrow keys until cell is fully visible or we detect no progress. - const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?) - let prevCx = box.x + box.width / 2; - let staleCount = 0; - for (let i = 0; i < 100; i++) { - await page.keyboard.press(direction); - await page.waitForTimeout(50); - box = await getBox(); - if (!box) break; - if (isFullyVisible(box)) break; - const cx = box.x + box.width / 2; - if (Math.abs(cx - prevCx) >= 1) { - staleCount = 0; - } else { - staleCount++; - if (staleCount >= MAX_STALE) break; - } - prevCx = cx; - } - await page.waitForTimeout(200); -} - -/** - * Click a cell in SpreadsheetDocument by logical coordinates. - * target: { row: number|'totals'|{colName: value}, column: string } - * Internal helper — called from clickElement when first arg is an object. - */ -export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { - ensureConnected(); - const formNum = await page.evaluate(detectFormScript()); - const { allCells, frameMap } = await scanSpreadsheetCells(formNum); - if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.'); - - const mapping = buildSpreadsheetMapping(allCells); - if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.'); - - const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping; - - // Resolve column (exact → endsWith " / X" → includes) - let colName = target.column; - if (!colMap.has(colName)) { - const available = colNames.filter(n => n); - const suffix = ' / ' + colName; - const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName)); - if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`); - colName = match; - } - const physCol = colMap.get(colName); - - // Resolve row → index into rows[] array - let rowIdx; - const row = target.row; - if (row === 'totals') { - if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.'); - rowIdx = totalsRowIdx; - } else if (typeof row === 'number') { - if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`); - rowIdx = dataRowIndices[row]; - } else if (typeof row === 'object') { - // Filter: { colName: value } — find first data row where column matches - const filterEntries = Object.entries(row); - const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; - const resolveCol = (name) => { - if (colMap.has(name)) return colMap.get(name); - const suffix = ' / ' + name; - const available = colNames.filter(n => n); - const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name)); - return m ? colMap.get(m) : null; - }; - rowIdx = dataRowIndices.find(i => { - return filterEntries.every(([fCol, fVal]) => { - const fColIdx = resolveCol(fCol); - if (fColIdx == null) return false; - const cellText = norm(rows[i][fColIdx]); - const search = norm(fVal); - return cellText === search || cellText.includes(search); - }); - }); - if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`); - } else { - throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.'); - } - - // Map rows[] index → physical row number - const physRow = sortedRows[rowIdx]; - const cellKey = `${physRow}_${physCol}`; - const frame = frameMap.get(cellKey); - if (!frame) { - // Cell exists in mapping but might be empty — try clicking anyway - throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`); - } - // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs. - const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first(); - // Scroll cell into view using arrow keys — the only reliable way to scroll - // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar. - await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv); - const box = await cellDiv.boundingBox(); - if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`); - - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; - if (modKey) await page.keyboard.down(modKey); - if (dbl) { - await page.mouse.dblclick(x, y); - } else { - await page.mouse.click(x, y); - } - if (modKey) await page.keyboard.up(modKey); - - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) }; - return state; -} - -/** - * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement). - * Returns { frameIndex, physRow, physCol, box } or null if not found. - */ -export async function findSpreadsheetCellByText(formNum, searchText) { - const { allCells, frameMap } = await scanSpreadsheetCells(formNum); - if (allCells.size === 0) return null; - - const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; - const target = norm(searchText); - - // Exact match first, then includes - let found = null; - for (const [key, cell] of allCells) { - if (norm(cell.t) === target) { found = { key, cell }; break; } - } - if (!found) { - for (const [key, cell] of allCells) { - if (norm(cell.t).includes(target)) { found = { key, cell }; break; } - } - } - if (!found) return null; - - const frame = frameMap.get(found.key); - if (!frame) return null; - - // Scroll cell into view using native arrow-key mechanism - const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first(); - await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv); - const box = await cellDiv.boundingBox(); - if (!box) return null; - - return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box }; -} - -/** - * Read report output (SpreadsheetDocumentField) rendered in iframes. - * 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes. - * Each cell is a div[x] inside a row div[y], text content in . - * - * Returns structured data: - * { title, headers, data: [{col: val}], totals: {col: val}, total } - * If header detection fails, falls back to { rows: string[][], total }. - */ -export async function readSpreadsheet() { - ensureConnected(); - const formNum = await page.evaluate(detectFormScript()); - - const { allCells } = await scanSpreadsheetCells(formNum); - - if (allCells.size === 0) { - // Check for state window messages (info bar) that explain why the report is empty - const err = await checkForErrors(); - const hint = err?.stateText?.length ? err.stateText.join('; ') : ''; - throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.')); - } - - const mapping = buildSpreadsheetMapping(allCells); - if (!mapping) { - // Fallback: return raw rows - const rowMap = new Map(); - let maxCol = 0; - for (const cell of allCells.values()) { - if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); - rowMap.get(cell.r).set(cell.c, cell.t); - if (cell.c > maxCol) maxCol = cell.c; - } - const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); - const rows = sortedRows.map(r => { - const cm = rowMap.get(r); - const arr = []; - for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); - return arr; - }); - return { rows, total: rows.length }; - } - - const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping; - - // Convert data rows to objects - const data = []; - let totals = null; - const toObj = (row) => { - const obj = {}; - for (let c = 0; c < colNames.length; c++) { - if (colNames[c] && row[c]) obj[colNames[c]] = row[c]; - } - return obj; - }; - - for (let i = dataStartIdx; i < rows.length; i++) { - if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; - const first = rows[i][0]?.trim().toLowerCase(); - if (first === 'итого' || first === 'всего') { - totals = toObj(rows[i]); - } else { - data.push(toObj(rows[i])); - } - } - - // Meta: title, params, filters from rows before header (superRow is part of header, not meta) - const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx); - let title = ''; - const meta = []; - for (let i = 0; i < metaEnd; i++) { - const parts = rows[i].filter(c => c); - if (!parts.length) continue; - if (!title) { title = parts.join(' '); continue; } - meta.push(parts.join(' ')); - } - - return { - title: title || undefined, - meta: meta.length ? meta : undefined, - headers: colNames.filter(n => n), - data, - totals: totals || undefined, - total: data.length, - }; -} // ============================================================ // Value selection (DLB/CB) — extracted to forms/select-value.mjs @@ -778,6 +208,7 @@ export { fillFields, fillField } from './forms/fill.mjs'; // clickElement dispatcher — extracted to core/click.mjs // ============================================================ export { clickElement } from './core/click.mjs'; +import { clickElement } from './core/click.mjs'; // ============================================================ // Close form — extracted to forms/close.mjs @@ -2074,382 +1505,11 @@ export async function deleteTableRow(row, { tab, table } = {}) { return { deleted: row, rowsBefore, rowsAfter, form: formData }; } -/** - * Filter the current list by field value, or search via search bar. - * - * Without field: simple search via the search bar (filters by all columns, no badge). - * With field: advanced search — clicks target column cell to auto-populate FieldSelector, - * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge. - * Handles text, reference (with Tab autocomplete), and date fields automatically. - * Multiple filters can be chained by calling filterList multiple times. - * - * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016") - * @param {object} [opts] - * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата") - * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact) - */ -export async function filterList(text, { field, exact } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('filterList: no form found'); +// ============================================================ +// List filters — extracted to table/filter.mjs +// ============================================================ +export { filterList, unfilterList } from './table/filter.mjs'; - if (!field) { - // --- Simple search: fill search input + Enter --- - const searchId = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? el.id : null; - })()`); - - if (searchId) { - await page.click(`[id="${searchId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'search', text }; - return state; - } - - // No search input — Ctrl+F opens advanced search on such forms. - // Click first grid cell then fall through to advanced search path below. - const firstCell = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0); - if (!grid) return null; - const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; - if (!rows.length) return null; - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (!cells.length) return null; - const r = cells[0].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); - await page.mouse.click(firstCell.x, firstCell.y); - await page.waitForTimeout(300); - field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) - } - - // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- - // Clicking a cell in the target column makes it active, so when Alt+F opens the - // advanced search dialog, FieldSelector is auto-populated with the correct field name. - // This avoids changing FieldSelector programmatically (which can cause errors). - const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim()); - - // 1. Click a cell in the target column to activate it (auto-populates FieldSelector). - // If the column isn't visible in the grid, click any cell and use DLB fallback later. - let needDlb = false; - const gridEl = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0); - if (!grid) return { error: 'no_grid' }; - const targetField = ${JSON.stringify(field)}; - const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; - let colIndex = -1; - let startsWithIdx = -1; - let includesIdx = -1; - for (let i = 0; i < headers.length; i++) { - const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); - if (!t) continue; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); - if (tl === fl) { colIndex = i; break; } - if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } - else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } - } - if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; - const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; - if (!rows.length) return { error: 'no_rows' }; - if (colIndex < 0) { - // Column not in grid — click first cell of first row, will use DLB to change field - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (!cells.length) return { error: 'no_cells' }; - const r = cells[0].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; - } - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (colIndex >= cells.length) return { error: 'cell_not_found' }; - const r = cells[colIndex].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`); - needDlb = !!gridEl.needDlb; - await page.mouse.click(gridEl.x, gridEl.y); - await page.waitForTimeout(500); - - // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu) - await page.keyboard.press('Alt+f'); - await page.waitForTimeout(2000); - - let dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum) { - // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск - await clickElement('Еще'); - await page.waitForTimeout(500); - const menu = await page.evaluate(readSubmenuScript()); - const searchItem = Array.isArray(menu) && menu.find(i => - i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск')); - if (!searchItem) { - await page.keyboard.press('Escape'); - throw new Error('filterList: advanced search dialog could not be opened'); - } - await page.mouse.click(searchItem.x, searchItem.y); - await page.waitForTimeout(2000); - dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum) { - throw new Error('filterList: advanced search dialog did not open'); - } - } - - // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown - // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) - if (needDlb && field) { - const fsInfo = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); - const dlb = document.getElementById(p + 'FieldSelector_DLB'); - return { - current: fsInput?.value?.trim() || '', - dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0, - dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0 - }; - })()`); - - if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) { - await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY); - await page.waitForTimeout(1500); - - const ddResult = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' }; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(field.toLowerCase())}); - const items = [...edd.querySelectorAll('div')].filter(el => - el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n')); - const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target) - || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target)); - if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) }; - const r = match.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() }; - })()`); - - if (ddResult.error) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`); - } - await page.mouse.click(ddResult.x, ddResult.y); - await page.waitForTimeout(3000); - } - } - - // 3. Read dialog state and fill Pattern - // Detect field type by Pattern's sibling buttons: - // - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value) - // - iDLB on Pattern → reference field (paste + Tab for autocomplete) - // - neither → plain text field (just paste) - const dialogInfo = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); - const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); - const ptLabel = ptInput?.closest('label'); - const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : []; - const isDate = btns.some(c => c.includes('iCalendB')); - const isRef = !isDate && btns.some(c => c.includes('iDLB')); - return { - fieldSelector: fsInput?.value?.trim() || '', - patternValue: ptInput?.value?.trim() || '', - patternId: ptInput?.id || '', - isDate, - isRef - }; - })()`); - - if (dialogInfo.isDate) { - // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste) - if (isDateValue && dialogInfo.patternValue !== text.trim()) { - await page.click(`[id="${dialogInfo.patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Home'); - await page.waitForTimeout(100); - await page.keyboard.press('Shift+End'); - await page.waitForTimeout(100); - await pasteText(text); - await page.waitForTimeout(500); - } - } else { - // Text or reference field: fill Pattern via clipboard paste - await page.click(`[id="${dialogInfo.patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - - if (dialogInfo.isRef) { - // Reference field: Tab triggers autocomplete to resolve text → reference value - await page.keyboard.press('Tab'); - await page.waitForTimeout(2000); - } - } - - // 3b. Switch CompareType if exact match requested (text fields only). - // Date/number: always exact, CompareType disabled. Reference: default exact (selects ref). - if (exact && !dialogInfo.isDate && !dialogInfo.isRef) { - const exactRadio = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - // Check if CompareType group is disabled (dates, numbers) - const group = document.getElementById(p + 'CompareType'); - if (group && group.classList.contains('disabled')) return { already: true }; - const el = document.getElementById(p + 'CompareType#2#radio'); - if (!el || el.offsetWidth === 0) return null; - if (el.classList.contains('select')) return { already: true }; - const r = el.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (exactRadio && !exactRadio.already) { - await page.mouse.click(exactRadio.x, exactRadio.y); - await page.waitForTimeout(300); - } - } - - // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked) - const findBtnCoords = await page.evaluate(`(() => { - const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); - const btn = btns.find(el => el.innerText?.trim() === 'Найти'); - if (!btn) return null; - const r = btn.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (findBtnCoords) { - await page.mouse.click(findBtnCoords.x, findBtnCoords.y); - } else { - await clickElement('Найти'); - } - await page.waitForTimeout(2000); - - // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). - // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals - // (e.g. a selection form that opened this advanced search). - for (let attempt = 0; attempt < 3; attempt++) { - const dialogVisible = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (!dialogVisible) break; - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'advanced', field, text, exact: !!exact }; - return state; -} - -/** - * Remove active filters/search from the current list. - * - * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field). - * With field: clicks the × button on the specific filter badge (selective removal). - * - * @param {object} [opts] - * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×) - */ -export async function unfilterList({ field } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('unfilterList: no form found'); - - if (field) { - // --- Selective: click × on specific filter badge --- - const closeBtn = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || ''; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(field.toLowerCase())}); - const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0); - for (const item of items) { - const titleEl = item.querySelector('.trainName'); - const title = ny(norm(titleEl?.innerText).toLowerCase()); - if (title === target || title.includes(target)) { - const close = item.querySelector('.trainClose'); - if (close) { - const r = close.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) }; - } - } - } - const available = items.map(item => norm(item.querySelector('.trainName')?.innerText)); - return { error: 'not_found', available }; - })()`); - - if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`); - await page.mouse.click(closeBtn.x, closeBtn.y); - await waitForStable(formNum); - - const state = await getFormState(); - state.unfiltered = { field: closeBtn.field }; - return state; - } - - // --- Clear ALL filters --- - - // 1. Remove all advanced filter badges (.trainItem × buttons) - for (let attempt = 0; attempt < 20; attempt++) { - const badge = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')] - .find(el => el.offsetWidth > 0); - if (!item) return null; - const close = item.querySelector('.trainClose'); - if (!close) return null; - const r = close.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (!badge) break; - await page.mouse.click(badge.x, badge.y); - await waitForStable(formNum); - } - - // 2. Cancel active search via Ctrl+Q - await page.keyboard.press('Control+q'); - await waitForStable(formNum); - - // 3. Clear simple search field if it has a value - const searchInfo = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? { id: el.id, value: el.value || '' } : null; - })()`); - - if (searchInfo?.value) { - await page.click(`[id="${searchInfo.id}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Delete'); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - } - - const state = await getFormState(); - state.unfiltered = true; - return state; -} // ============================================================ // Recording, captions, narration, highlight — extracted to recording/* diff --git a/.claude/skills/web-test/scripts/core/click.mjs b/.claude/skills/web-test/scripts/core/click.mjs index c1567c76..160a756c 100644 --- a/.claude/skills/web-test/scripts/core/click.mjs +++ b/.claude/skills/web-test/scripts/core/click.mjs @@ -12,10 +12,11 @@ import { waitForStable, startNetworkMonitor } from './wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick } from './helpers.mjs'; import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; -// Spreadsheet cell handlers and getFormState live in browser.mjs. import { - clickSpreadsheetCell, findSpreadsheetCellByText, getFormState, -} from '../browser.mjs'; + clickSpreadsheetCell, findSpreadsheetCellByText, +} from '../table/spreadsheet.mjs'; +// getFormState still in browser.mjs. +import { getFormState } from '../browser.mjs'; /** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). * First argument can also be an object { row, column } to click a SpreadsheetDocument cell. */ diff --git a/.claude/skills/web-test/scripts/table/filter.mjs b/.claude/skills/web-test/scripts/table/filter.mjs new file mode 100644 index 00000000..b1a398a0 --- /dev/null +++ b/.claude/skills/web-test/scripts/table/filter.mjs @@ -0,0 +1,389 @@ +// web-test table/filter v1.16 — filterList / unfilterList — simple search + advanced-column filter badges. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs'; +import { detectFormScript, resolveGridScript } from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; +import { waitForStable, waitForCondition } from '../core/wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { safeClick } from '../core/helpers.mjs'; +import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; +// pasteText + getFormState + clickElement still in browser.mjs. +import { pasteText, getFormState, clickElement } from '../browser.mjs'; + +/** + * Filter the current list by field value, or search via search bar. + * + * Without field: simple search via the search bar (filters by all columns, no badge). + * With field: advanced search — clicks target column cell to auto-populate FieldSelector, + * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge. + * Handles text, reference (with Tab autocomplete), and date fields automatically. + * Multiple filters can be chained by calling filterList multiple times. + * + * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016") + * @param {object} [opts] + * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата") + * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact) + */ +export async function filterList(text, { field, exact } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('filterList: no form found'); + + if (!field) { + // --- Simple search: fill search input + Enter --- + const searchId = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? el.id : null; + })()`); + + if (searchId) { + await page.click(`[id="${searchId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'search', text }; + return state; + } + + // No search input — Ctrl+F opens advanced search on such forms. + // Click first grid cell then fall through to advanced search path below. + const firstCell = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return null; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return null; + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return null; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); + await page.mouse.click(firstCell.x, firstCell.y); + await page.waitForTimeout(300); + field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) + } + + // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- + // Clicking a cell in the target column makes it active, so when Alt+F opens the + // advanced search dialog, FieldSelector is auto-populated with the correct field name. + // This avoids changing FieldSelector programmatically (which can cause errors). + const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim()); + + // 1. Click a cell in the target column to activate it (auto-populates FieldSelector). + // If the column isn't visible in the grid, click any cell and use DLB fallback later. + let needDlb = false; + const gridEl = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return { error: 'no_grid' }; + const targetField = ${JSON.stringify(field)}; + const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; + let colIndex = -1; + let startsWithIdx = -1; + let includesIdx = -1; + for (let i = 0; i < headers.length; i++) { + const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); + if (!t) continue; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); + if (tl === fl) { colIndex = i; break; } + if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } + else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } + } + if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return { error: 'no_rows' }; + if (colIndex < 0) { + // Column not in grid — click first cell of first row, will use DLB to change field + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return { error: 'no_cells' }; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; + } + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (colIndex >= cells.length) return { error: 'cell_not_found' }; + const r = cells[colIndex].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`); + needDlb = !!gridEl.needDlb; + await page.mouse.click(gridEl.x, gridEl.y); + await page.waitForTimeout(500); + + // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu) + await page.keyboard.press('Alt+f'); + await page.waitForTimeout(2000); + + let dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum) { + // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск + await clickElement('Еще'); + await page.waitForTimeout(500); + const menu = await page.evaluate(readSubmenuScript()); + const searchItem = Array.isArray(menu) && menu.find(i => + i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск')); + if (!searchItem) { + await page.keyboard.press('Escape'); + throw new Error('filterList: advanced search dialog could not be opened'); + } + await page.mouse.click(searchItem.x, searchItem.y); + await page.waitForTimeout(2000); + dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum) { + throw new Error('filterList: advanced search dialog did not open'); + } + } + + // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown + // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) + if (needDlb && field) { + const fsInfo = await page.evaluate(`(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); + const dlb = document.getElementById(p + 'FieldSelector_DLB'); + return { + current: fsInput?.value?.trim() || '', + dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0, + dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0 + }; + })()`); + + if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) { + await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY); + await page.waitForTimeout(1500); + + const ddResult = await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' }; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(field.toLowerCase())}); + const items = [...edd.querySelectorAll('div')].filter(el => + el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n')); + const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target) + || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target)); + if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) }; + const r = match.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() }; + })()`); + + if (ddResult.error) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`); + } + await page.mouse.click(ddResult.x, ddResult.y); + await page.waitForTimeout(3000); + } + } + + // 3. Read dialog state and fill Pattern + // Detect field type by Pattern's sibling buttons: + // - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value) + // - iDLB on Pattern → reference field (paste + Tab for autocomplete) + // - neither → plain text field (just paste) + const dialogInfo = await page.evaluate(`(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); + const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + const ptLabel = ptInput?.closest('label'); + const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : []; + const isDate = btns.some(c => c.includes('iCalendB')); + const isRef = !isDate && btns.some(c => c.includes('iDLB')); + return { + fieldSelector: fsInput?.value?.trim() || '', + patternValue: ptInput?.value?.trim() || '', + patternId: ptInput?.id || '', + isDate, + isRef + }; + })()`); + + if (dialogInfo.isDate) { + // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste) + if (isDateValue && dialogInfo.patternValue !== text.trim()) { + await page.click(`[id="${dialogInfo.patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Home'); + await page.waitForTimeout(100); + await page.keyboard.press('Shift+End'); + await page.waitForTimeout(100); + await pasteText(text); + await page.waitForTimeout(500); + } + } else { + // Text or reference field: fill Pattern via clipboard paste + await page.click(`[id="${dialogInfo.patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + + if (dialogInfo.isRef) { + // Reference field: Tab triggers autocomplete to resolve text → reference value + await page.keyboard.press('Tab'); + await page.waitForTimeout(2000); + } + } + + // 3b. Switch CompareType if exact match requested (text fields only). + // Date/number: always exact, CompareType disabled. Reference: default exact (selects ref). + if (exact && !dialogInfo.isDate && !dialogInfo.isRef) { + const exactRadio = await page.evaluate(`(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + // Check if CompareType group is disabled (dates, numbers) + const group = document.getElementById(p + 'CompareType'); + if (group && group.classList.contains('disabled')) return { already: true }; + const el = document.getElementById(p + 'CompareType#2#radio'); + if (!el || el.offsetWidth === 0) return null; + if (el.classList.contains('select')) return { already: true }; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (exactRadio && !exactRadio.already) { + await page.mouse.click(exactRadio.x, exactRadio.y); + await page.waitForTimeout(300); + } + } + + // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked) + const findBtnCoords = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); + const btn = btns.find(el => el.innerText?.trim() === 'Найти'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (findBtnCoords) { + await page.mouse.click(findBtnCoords.x, findBtnCoords.y); + } else { + await clickElement('Найти'); + } + await page.waitForTimeout(2000); + + // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). + // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals + // (e.g. a selection form that opened this advanced search). + for (let attempt = 0; attempt < 3; attempt++) { + const dialogVisible = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (!dialogVisible) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'advanced', field, text, exact: !!exact }; + return state; +} + +/** + * Remove active filters/search from the current list. + * + * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field). + * With field: clicks the × button on the specific filter badge (selective removal). + * + * @param {object} [opts] + * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×) + */ +export async function unfilterList({ field } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('unfilterList: no form found'); + + if (field) { + // --- Selective: click × on specific filter badge --- + const closeBtn = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || ''; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(field.toLowerCase())}); + const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0); + for (const item of items) { + const titleEl = item.querySelector('.trainName'); + const title = ny(norm(titleEl?.innerText).toLowerCase()); + if (title === target || title.includes(target)) { + const close = item.querySelector('.trainClose'); + if (close) { + const r = close.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) }; + } + } + } + const available = items.map(item => norm(item.querySelector('.trainName')?.innerText)); + return { error: 'not_found', available }; + })()`); + + if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`); + await page.mouse.click(closeBtn.x, closeBtn.y); + await waitForStable(formNum); + + const state = await getFormState(); + state.unfiltered = { field: closeBtn.field }; + return state; + } + + // --- Clear ALL filters --- + + // 1. Remove all advanced filter badges (.trainItem × buttons) + for (let attempt = 0; attempt < 20; attempt++) { + const badge = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')] + .find(el => el.offsetWidth > 0); + if (!item) return null; + const close = item.querySelector('.trainClose'); + if (!close) return null; + const r = close.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!badge) break; + await page.mouse.click(badge.x, badge.y); + await waitForStable(formNum); + } + + // 2. Cancel active search via Ctrl+Q + await page.keyboard.press('Control+q'); + await waitForStable(formNum); + + // 3. Clear simple search field if it has a value + const searchInfo = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? { id: el.id, value: el.value || '' } : null; + })()`); + + if (searchInfo?.value) { + await page.click(`[id="${searchInfo.id}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Delete'); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + } + + const state = await getFormState(); + state.unfiltered = true; + return state; +} diff --git a/.claude/skills/web-test/scripts/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/table/spreadsheet.mjs new file mode 100644 index 00000000..c2693a8e --- /dev/null +++ b/.claude/skills/web-test/scripts/table/spreadsheet.mjs @@ -0,0 +1,582 @@ +// web-test table/spreadsheet v1.16 — readTable, readSpreadsheet, scanSpreadsheetCells, scroll/click helpers for SpreadsheetDocument. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, ensureConnected, normYo } from '../core/state.mjs'; +import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; +import { waitForStable } from '../core/wait.mjs'; + +/** Read structured table data with pagination. Returns columns, rows, total count. */ +export async function readTable({ maxRows = 20, offset = 0, table } = {}) { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('readTable: no form found'); + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); +} + +// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- + +/** + * Scan spreadsheet iframes for the current form and collect all cells. + * Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> } + * where frameIndex is the Playwright frames[] index (1-based, 0 = main). + */ +async function scanSpreadsheetCells(formNum) { + const prefix = `form${formNum ?? 0}_`; + const iframeHandles = await page.$$('iframe'); + + const allCells = new Map(); + const frameMap = new Map(); // key 'r_c' → Playwright Frame object + + for (const handle of iframeHandles) { + const ok = await handle.evaluate((f, pfx) => { + if (f.offsetWidth < 100) return false; + let el = f.parentElement; + for (let d = 0; el && d < 30; d++, el = el.parentElement) { + if (el.id && el.id.startsWith(pfx)) return true; + } + return false; + }, prefix); + if (!ok) continue; + + const frame = await handle.contentFrame(); + if (!frame) continue; + + try { + const cells = await frame.evaluate(`(() => { + const cells = []; + document.querySelectorAll('div[x]').forEach(d => { + const span = d.querySelector('span'); + const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || ''; + if (!text) return; + const rowDiv = d.parentElement; + const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null; + const col = d.getAttribute('x'); + if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text }); + }); + return cells; + })()`); + for (const cell of cells) { + const key = `${cell.r}_${cell.c}`; + if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) { + allCells.set(key, cell); + frameMap.set(key, frame); + } + } + } catch { /* skip inaccessible frames */ } + } + return { allCells, frameMap }; +} + +/** + * Build structured mapping from raw cells: headers, column map, data/totals row indices. + * Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap } + * or null if header detection fails. + */ +function buildSpreadsheetMapping(allCells) { + const rowMap = new Map(); + let maxCol = 0; + for (const cell of allCells.values()) { + if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); + rowMap.get(cell.r).set(cell.c, cell.t); + if (cell.c > maxCol) maxCol = cell.c; + } + + const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); + const rows = sortedRows.map(r => { + const cm = rowMap.get(r); + const arr = []; + for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); + return arr; + }); + + // Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78" + // Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification. + const isNumericVal = (c) => { + if (!c || !/\d/.test(c)) return false; + const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, ''); + return /^\d[\d,]*$/.test(s); + }; + // Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus). + // Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51". + const isDataNumericVal = (c) => { + if (!isNumericVal(c)) return false; + return /[\s\u00a0,]/.test(c) || /^-/.test(c); + }; + const hasNumber = (row) => row.some(c => isNumericVal(c)); + const nonEmpty = (row) => row.filter(c => c !== '').length; + + // Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx. + // Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor). + const buildRichMapping = (detailIdx, firstDataIdx) => { + let groupIdx = -1; + if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1; + + const detailRow = rows[detailIdx]; + const groupRow = groupIdx >= 0 ? rows[groupIdx] : null; + + // Detect optional third header level above group row (bounds carry-forward) + let superRow = null; + if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) { + superRow = rows[groupIdx - 1]; + } + + // Build column names (group + detail merge) + const groupFilled = new Array(maxCol + 1).fill(''); + if (groupRow) { + let cur = ''; + for (let c = 0; c <= maxCol; c++) { + if (groupRow[c]) { + cur = groupRow[c]; + } else if (superRow && superRow[c]) { + // New top-level header starts here — stop carry-forward + cur = ''; + } + groupFilled[c] = cur; + } + } + + const detailCounts = {}; + for (let c = 0; c <= maxCol; c++) { + const n = detailRow[c]; + if (n) detailCounts[n] = (detailCounts[n] || 0) + 1; + } + + // Detect DCS column codes (К1, К2, ...) — always prefix with group when present + const detailNonEmpty = detailRow.filter(c => c); + const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c)); + + const colNames = []; + for (let c = 0; c <= maxCol; c++) { + const detail = detailRow[c]; + const group = groupFilled[c]; + const sup = superRow ? superRow[c] : ''; + if (detail) { + // Prefer group prefix; fall back to superRow for DCS code columns without sub-group + const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : ''); + const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === '')); + colNames.push(needPrefix ? `${prefix} / ${detail}` : detail); + } else if (group) { + colNames.push(group); + } else if (sup) { + colNames.push(sup); + } else { + colNames.push(null); + } + } + + const colMap = new Map(); + for (let c = 0; c < colNames.length; c++) { + if (colNames[c]) colMap.set(colNames[c], c); + } + + // Classify data rows: separate data indices and totals index + const dataRowIndices = []; + let totalsRowIdx = -1; + for (let i = firstDataIdx; i < rows.length; i++) { + if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; + const first = rows[i][0]?.trim().toLowerCase(); + if (first === 'итого' || first === 'всего') { + totalsRowIdx = i; + } else { + dataRowIndices.push(i); + } + } + + const superRowIdx = superRow ? groupIdx - 1 : -1; + + return { + rows, sortedRows, maxCol, colNames, colMap, + headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx, + dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx, + rowMap, hasNumber, nonEmpty, + }; + }; + + // --- Level 1: DCS-code row anchor --- + // ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker. + // Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб"). + for (let i = 0; i < rows.length; i++) { + const detailNonEmpty = rows[i].filter(c => c); + if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) { + // Find first non-empty row after the К-codes row as data start + let firstDataIdx = rows.length; + for (let j = i + 1; j < rows.length; j++) { + if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; } + } + return buildRichMapping(i, firstDataIdx); + } + } + + // --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) --- + let firstDataIdx = rows.length; + for (let i = 0; i < rows.length; i++) { + if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; } + } + if (firstDataIdx === rows.length) { + for (let i = 0; i < rows.length; i++) { + if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; } + } + } + + if (firstDataIdx < rows.length) { + let detailIdx = -1; + for (let i = firstDataIdx - 1; i >= 0; i--) { + if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; } + } + if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx); + } + + // --- Level 3: single-row header fallback (text-only data, query console) --- + // First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition. + let headerIdx = -1; + for (let i = 0; i < rows.length; i++) { + if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; } + } + // Single-column tables: accept nonEmpty >= 1 + if (headerIdx === -1 && maxCol === 0) { + for (let i = 0; i < rows.length; i++) { + if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; } + } + } + if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total } + + const detailRow = rows[headerIdx]; + const colNames = []; + for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null); + const colMap = new Map(); + for (let c = 0; c < colNames.length; c++) { + if (colNames[c]) colMap.set(colNames[c], c); + } + + const dataRowIndices = []; + let totalsRowIdx = -1; + for (let i = headerIdx + 1; i < rows.length; i++) { + if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; + const first = rows[i][0]?.trim().toLowerCase(); + if (first === 'итого' || first === 'всего') { + totalsRowIdx = i; + } else { + dataRowIndices.push(i); + } + } + + return { + rows, sortedRows, maxCol, colNames, colMap, + headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1, + dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx, + rowMap, hasNumber, nonEmpty, + }; +} + +/** + * Scroll SpreadsheetDocument to make a cell visible using arrow keys. + * Uses native platform scroll — keeps headers, data, and scrollbar synchronized. + * + * How it works: + * 1. Check target cell visibility via Playwright boundingBox (page-level coords). + * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay. + * This is the same native click that clickSpreadsheetCell uses — it gives keyboard + * focus to the spreadsheet and keeps headers/data/scrollbar in sync. + * (frame.locator().click() bypasses overlay → desyncs frozen headers; + * page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.) + * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport. + * + * @param {Frame} frame - Playwright Frame containing the spreadsheet cells + * @param {number} physRow - physical row (y attribute) in the frame + * @param {number} physCol - physical column (x attribute) in the frame + * @param {Locator} cellLoc - Playwright locator for the target cell (from caller) + */ +async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { + const pageVw = await page.evaluate('window.innerWidth'); + // Get iframe bounds — the actual visible region on page. + // The iframe may extend behind the section panel on the left, so cells with + // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel. + const frameElm = await frame.frameElement(); + const frameBox = await frameElm.boundingBox(); + const visLeft = frameBox ? frameBox.x : 0; + const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw; + + const getBox = async () => { + try { return await cellLoc.boundingBox({ timeout: 500 }); } + catch { return null; } + }; + const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight; + + let box = await getBox(); + if (!box) return; // cell not in DOM + if (isFullyVisible(box)) return; + + const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft'; + + // Find a fully-visible cell to click for focus. + // Prefer cells in the target row (scrollable area), fall back to any row. + const targetRowSel = `div[y="${physRow}"] div[x]`; + const anyRowSel = 'div[x]'; + let focusClicked = false; + for (const sel of [targetRowSel, anyRowSel]) { + const locs = frame.locator(sel); + const count = await locs.count(); + const candidates = []; + for (let ci = 0; ci < count; ci++) { + const b = await locs.nth(ci).boundingBox(); + if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) { + candidates.push({ ci, box: b }); + } + } + if (candidates.length === 0) continue; + candidates.sort((a, b) => a.box.x - b.box.x); + // ArrowRight → rightmost fully-visible (each press scrolls right immediately) + // ArrowLeft → leftmost fully-visible (each press scrolls left immediately) + const pick = direction === 'ArrowRight' + ? candidates[candidates.length - 1] + : candidates[0]; + // Native click through overlay — gives keyboard focus + no header desync. + await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2); + await page.waitForTimeout(100); + focusClicked = true; + break; + } + if (!focusClicked) return; // no visible cells — can't scroll + + // Arrow keys until cell is fully visible or we detect no progress. + const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?) + let prevCx = box.x + box.width / 2; + let staleCount = 0; + for (let i = 0; i < 100; i++) { + await page.keyboard.press(direction); + await page.waitForTimeout(50); + box = await getBox(); + if (!box) break; + if (isFullyVisible(box)) break; + const cx = box.x + box.width / 2; + if (Math.abs(cx - prevCx) >= 1) { + staleCount = 0; + } else { + staleCount++; + if (staleCount >= MAX_STALE) break; + } + prevCx = cx; + } + await page.waitForTimeout(200); +} + +/** + * Click a cell in SpreadsheetDocument by logical coordinates. + * target: { row: number|'totals'|{colName: value}, column: string } + * Internal helper — called from clickElement when first arg is an object. + */ +export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + const { allCells, frameMap } = await scanSpreadsheetCells(formNum); + if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.'); + + const mapping = buildSpreadsheetMapping(allCells); + if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.'); + + const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping; + + // Resolve column (exact → endsWith " / X" → includes) + let colName = target.column; + if (!colMap.has(colName)) { + const available = colNames.filter(n => n); + const suffix = ' / ' + colName; + const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName)); + if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`); + colName = match; + } + const physCol = colMap.get(colName); + + // Resolve row → index into rows[] array + let rowIdx; + const row = target.row; + if (row === 'totals') { + if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.'); + rowIdx = totalsRowIdx; + } else if (typeof row === 'number') { + if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`); + rowIdx = dataRowIndices[row]; + } else if (typeof row === 'object') { + // Filter: { colName: value } — find first data row where column matches + const filterEntries = Object.entries(row); + const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; + const resolveCol = (name) => { + if (colMap.has(name)) return colMap.get(name); + const suffix = ' / ' + name; + const available = colNames.filter(n => n); + const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name)); + return m ? colMap.get(m) : null; + }; + rowIdx = dataRowIndices.find(i => { + return filterEntries.every(([fCol, fVal]) => { + const fColIdx = resolveCol(fCol); + if (fColIdx == null) return false; + const cellText = norm(rows[i][fColIdx]); + const search = norm(fVal); + return cellText === search || cellText.includes(search); + }); + }); + if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`); + } else { + throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.'); + } + + // Map rows[] index → physical row number + const physRow = sortedRows[rowIdx]; + const cellKey = `${physRow}_${physCol}`; + const frame = frameMap.get(cellKey); + if (!frame) { + // Cell exists in mapping but might be empty — try clicking anyway + throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`); + } + // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs. + const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first(); + // Scroll cell into view using arrow keys — the only reliable way to scroll + // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar. + await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv); + const box = await cellDiv.boundingBox(); + if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`); + + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; + if (modKey) await page.keyboard.down(modKey); + if (dbl) { + await page.mouse.dblclick(x, y); + } else { + await page.mouse.click(x, y); + } + if (modKey) await page.keyboard.up(modKey); + + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) }; + return state; +} + +/** + * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement). + * Returns { frameIndex, physRow, physCol, box } or null if not found. + */ +export async function findSpreadsheetCellByText(formNum, searchText) { + const { allCells, frameMap } = await scanSpreadsheetCells(formNum); + if (allCells.size === 0) return null; + + const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; + const target = norm(searchText); + + // Exact match first, then includes + let found = null; + for (const [key, cell] of allCells) { + if (norm(cell.t) === target) { found = { key, cell }; break; } + } + if (!found) { + for (const [key, cell] of allCells) { + if (norm(cell.t).includes(target)) { found = { key, cell }; break; } + } + } + if (!found) return null; + + const frame = frameMap.get(found.key); + if (!frame) return null; + + // Scroll cell into view using native arrow-key mechanism + const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first(); + await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv); + const box = await cellDiv.boundingBox(); + if (!box) return null; + + return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box }; +} + +/** + * Read report output (SpreadsheetDocumentField) rendered in iframes. + * 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes. + * Each cell is a div[x] inside a row div[y], text content in . + * + * Returns structured data: + * { title, headers, data: [{col: val}], totals: {col: val}, total } + * If header detection fails, falls back to { rows: string[][], total }. + */ +export async function readSpreadsheet() { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + + const { allCells } = await scanSpreadsheetCells(formNum); + + if (allCells.size === 0) { + // Check for state window messages (info bar) that explain why the report is empty + const err = await checkForErrors(); + const hint = err?.stateText?.length ? err.stateText.join('; ') : ''; + throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.')); + } + + const mapping = buildSpreadsheetMapping(allCells); + if (!mapping) { + // Fallback: return raw rows + const rowMap = new Map(); + let maxCol = 0; + for (const cell of allCells.values()) { + if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); + rowMap.get(cell.r).set(cell.c, cell.t); + if (cell.c > maxCol) maxCol = cell.c; + } + const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); + const rows = sortedRows.map(r => { + const cm = rowMap.get(r); + const arr = []; + for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); + return arr; + }); + return { rows, total: rows.length }; + } + + const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping; + + // Convert data rows to objects + const data = []; + let totals = null; + const toObj = (row) => { + const obj = {}; + for (let c = 0; c < colNames.length; c++) { + if (colNames[c] && row[c]) obj[colNames[c]] = row[c]; + } + return obj; + }; + + for (let i = dataStartIdx; i < rows.length; i++) { + if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; + const first = rows[i][0]?.trim().toLowerCase(); + if (first === 'итого' || first === 'всего') { + totals = toObj(rows[i]); + } else { + data.push(toObj(rows[i])); + } + } + + // Meta: title, params, filters from rows before header (superRow is part of header, not meta) + const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx); + let title = ''; + const meta = []; + for (let i = 0; i < metaEnd; i++) { + const parts = rows[i].filter(c => c); + if (!parts.length) continue; + if (!title) { title = parts.join(' '); continue; } + meta.push(parts.join(' ')); + } + + return { + title: title || undefined, + meta: meta.length ? meta : undefined, + headers: colNames.filter(n => n), + data, + totals: totals || undefined, + total: data.length, + }; +}