diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 5d34f789..2a7cf11d 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -165,7 +165,7 @@ const form = await getFormState(); ### Reading data -#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset }` +#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset, hasMore }` Read actual grid data with pagination. Each row is `{ columnName: value }`. | Option | Default | Description | @@ -183,10 +183,22 @@ Special row fields: - `hierarchical: true` — list has groups (on result object) - `viewMode: 'tree'` — tree view active (on result object) +**`total` is misleading for long lists.** 1С virtualizes both dynamic lists and form tabular sections — the DOM holds only a window of visible rows. `total` / `shown` count what's *loaded right now*, not the size of the underlying collection. Use **`hasMore`** to know if there's more data outside the window: + +```js +const t = await readTable(); +// t.hasMore = { above: false, below: true } ← form tabular section, scrollbar visible +// t.hasMore = { below: true } ← dynamic list (catalog/journal/register) +// t.hasMore = { below: false } ← everything visible / end of list reached +``` + +- `hasMore.below` — always present. `true` ⇒ scrolling down (PageDown / `clickElement` with `scroll:true`) will reveal more rows. +- `hasMore.above` — only present for tabular sections with a visible scrollbar widget. Dynamic lists hide their scrollbar so we cannot detect "above" reliably; treat absence as unknown. + ```js const t = await readTable({ maxRows: 50 }); console.log('Columns:', t.columns); -console.log('Rows:', t.rows.length, 'of', t.total); +console.log('Loaded:', t.shown, 'rows; more below:', t.hasMore.below); // Pagination: const page2 = await readTable({ maxRows: 50, offset: 50 }); ``` @@ -219,7 +231,7 @@ Sections + all open tabs. **Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`. -#### `clickElement(text, { dblclick?, table?, expand?, modifier? })` → form state +#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match). - `table` — scope button search to a specific grid's command panel (by name from `tables[]`): @@ -250,25 +262,31 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match). const t = await readTable(); t.rows.filter(r => r._selected); // rows with _selected: true ``` -- **SpreadsheetDocument cells** (report drill-down): first argument can be `{ row, column }` object to click a cell in a rendered report. Coordinates match `readSpreadsheet()` output: +- **Cell click by (row, column)** — first argument as `{ row, column }`. Routes: spreadsheet on form → spreadsheet drill-down; otherwise → grid cell. Pass `table: 'GridName'` to force a specific grid when both are present. + + Spreadsheet report drill-down: ```js const report = await readSpreadsheet(); // report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000', ... } - - // By data row index + column header name - await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); - - // By cell value filter (fuzzy match) - await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); - - // Totals row - await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); + await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); // by index + await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); // by filter + await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); // totals row + await clickElement('150 000', { dblclick: true }); // fallback: by text ``` - Text search also works as fallback — searches inside spreadsheet iframes: + + Form grid cell (catalog list, journal, table part). Off-viewport columns auto-scroll horizontally (works around frozen columns). Use `scroll: true | number` for filter-based rows outside the current DOM window: ```js - await clickElement('150 000', { dblclick: true }); // finds cell by text in report + await clickElement({ row: 0, column: 'Количество' }, { table: 'Товары', dblclick: true }); + await clickElement({ row: { 'Номенклатура': 'Бумага' }, column: 'Цена' }, { table: 'Товары' }); + await clickElement({ row: { 'Номер': '0000-000601' }, column: 'Сумма' }, + { table: 'Реализации', scroll: true }); // PageDown loop, max 50 ``` + Gotchas: + - `row: ` is the index in the **current DOM window**, not absolute — 1С virtualizes long lists. `row: 0` is the topmost loaded row after any prior scroll. For arbitrary rows in a long list use `row: { col: val }` + `scroll: true`. + - `scroll: true` walks **down only** (PageDown). For going up first press `Home` via `getPage().keyboard` or narrow with `filterList`. + - First matching row wins on duplicate filter matches — refine the filter to disambiguate. + #### `fillFields({ name: value })` → form state with `filled` Fill form fields by label (fuzzy match). Auto-detects field type. diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 1db2747c..25cc5e0b 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -1,4 +1,4 @@ -// web-test dom v1.14 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.16 — facade re-exporting injectable DOM scripts from dom/ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Facade: re-exports DOM selector & semantic mapping script generators. @@ -60,6 +60,10 @@ export { isTreeGridScript, findGridHeadCenterCoordsScript, getSelectedOrLastRowIndexScript, + findGridCellScript, + findFocusCellScript, + snapshotGridScript, + resolveCellTargetScript, } from './dom/grid.mjs'; export { diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 28bc9eec..72c69827 100644 --- a/.claude/skills/web-test/scripts/dom/grid.mjs +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -1,4 +1,4 @@ -// web-test dom/grid v1.3 — grid resolution + table reading + edit-time helpers +// web-test dom/grid v1.5 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** @@ -241,7 +241,21 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto } const isTree = !!body.querySelector('.gridBoxTree'); const hasGroups = rows.some(r => r._kind === 'group'); - const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length }; + // Virtualization-aware "has more" signal: + // - Tabular sections render a visible scrollbar widget (#vertScroll_* with class "scrollV" and non-zero size). + // Its child tracks expose exact above/below pixel offsets relative to the slider. + // - Dynamic lists hide the widget (empty class, 0×0). We can only infer below via scrollHeight>clientHeight. + let hasMore; + const vsId = 'vertScroll_' + (grid.id || '').replace(p, ''); + const vs = grid.querySelector('#' + CSS.escape(vsId)); + if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) { + const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0; + const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0; + hasMore = { above: back > 0, below: next > 0 }; + } else { + hasMore = { below: body.scrollHeight > body.clientHeight }; + } + const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore }; if (isTree) result.viewMode = 'tree'; if (hasGroups) result.hierarchical = true; return result; @@ -381,3 +395,298 @@ export function scanGridRowsScript(formNum, searchLower) { return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup }; })()`; } + +// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ─────── + +/** + * Resolve a target cell in a grid by (row, column). + * - `column` matched: exact (case+ё-insensitive) → endsWith ' / X' → includes. + * - `row`: number = index in current DOM window; object = {col: value, ...} filter + * (matches first non-group/parent row where every column condition passes). + * + * Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`. + * + * Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport. + * Callers should horizontally scroll first if `visible === false`. + */ +export function findGridCellScript(formNum, gridSelector, { row, column }) { + const p = `form${formNum}_`; + return `(() => { + const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim(); + const lo = s => norm(s).toLowerCase(); + + const p = ${JSON.stringify(p)}; + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`}; + if (!grid) return { error: 'no_grid' }; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return { error: 'no_grid_structure' }; + + // Header X-ranges (mirror of readTableScript logic, simplified). We also + // remember whether each header is frozen (gridBoxFix) — frozen and scrollable + // columns can share X coordinates after horizontal scroll, so cell matching + // must respect the frozen/scrollable partition. + const headLine = head.querySelector('.gridLine') || head; + const headers = [...headLine.children] + .filter(c => c.offsetWidth > 0) + .map(c => { + const textEl = c.querySelector('.gridBoxText'); + const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || ''; + const r = c.getBoundingClientRect(); + return { text, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') }; + }) + .filter(h => h.text); + + const resolveCol = (name) => { + const suffix = ' / ' + name; + return headers.find(h => lo(h.text) === lo(name)) + || headers.find(h => h.text.endsWith(suffix)) + || headers.find(h => lo(h.text).includes(lo(name))); + }; + + const targetCol = ${JSON.stringify(column)}; + const col = resolveCol(targetCol); + if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.text) }; + + const lines = [...body.querySelectorAll('.gridLine')]; + if (lines.length === 0) return { error: 'empty_grid' }; + + // Match cell to column by X overlap, but only among cells with the same + // fixed/scrollable kind as the header. After horizontal scroll a scrollable + // cell may have the same x as a frozen one — without this guard cellAtColX + // would silently return the frozen cell for a scrollable header. + const cellAtColX = (line, c) => [...line.children] + .filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed) + .find(b => { + const r = b.getBoundingClientRect(); + const cx = r.x + r.width / 2; + return cx >= c.x && cx < c.right; + }); + const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || ''); + + const target = ${JSON.stringify(row)}; + let line, rowIdx; + if (typeof target === 'number') { + if (target < 0 || target >= lines.length) { + return { error: 'row_out_of_range', row: target, loaded: lines.length }; + } + line = lines[target]; + rowIdx = target; + } else if (target && typeof target === 'object') { + const entries = Object.entries(target); + const colsByKey = {}; + for (const [k] of entries) { + const c = resolveCol(k); + if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.text) }; + colsByKey[k] = c; + } + const matches = (ln) => { + for (const [k, v] of entries) { + const c = colsByKey[k]; + const cell = cellAtColX(ln, c); + const txt = cellText(cell); + const wanted = lo(v); + if (!txt) return false; + const t = txt.toLowerCase(); + if (!(t === wanted || t.includes(wanted))) return false; + } + return true; + }; + rowIdx = lines.findIndex(matches); + if (rowIdx < 0) return { error: 'row_not_found', filter: target }; + line = lines[rowIdx]; + } else { + return { error: 'invalid_row_type' }; + } + + const cell = cellAtColX(line, col); + if (!cell) return { error: 'cell_not_in_dom', column: col.text, rowIdx }; + const r = cell.getBoundingClientRect(); + const gridBox = grid.getBoundingClientRect(); + // Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even + // when the rest scrolls horizontally. For non-frozen cells, "visible" means + // inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells + // are always visible by definition. + const isFixed = cell.classList.contains('gridBoxFix'); + let scrollableLeft = gridBox.x; + if (!isFixed) { + [...line.children].forEach(b => { + if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) { + const br = b.getBoundingClientRect(); + if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width; + } + }); + } + // "Visible enough to click" — the cell's CENTER is inside the scrollable area + // and the cell's right edge is inside the grid. Strict left-edge check would + // reject cells that 1С rendered touching the frozen-column boundary (off by 1px). + const center = r.x + r.width / 2; + const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width); + return { + x: Math.round(r.x + r.width / 2), + y: Math.round(r.y + r.height / 2), + cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width), + gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width), + scrollableLeft: Math.round(scrollableLeft), + columnText: col.text, rowIdx, isFixed, + cellText: cellText(cell), + visible + }; + })()`; +} + +/** + * Pick coordinates for a focus-click on a safe cell within the grid. + * + * Used both for vertical reveal-loop focus and for horizontal-scroll edge focus. + * The caller passes a profile that selects which row, which cells to exclude, + * and (for horizontal scroll) which edge of the row to take. + * + * @param {string} gridSelector + * @param {object} opts + * @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row. + * @param {'ArrowRight'|'ArrowLeft'} [opts.direction] + * - When set, restricts to non-frozen FULLY visible cells and picks the edge + * cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft). + * - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles). + * + * Always prefers non-checkbox cells (center-click on a checkbox would toggle it). + * + * Returns `{ x, y } | null`. + */ +export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const lines = [...body.querySelectorAll('.gridLine')]; + if (!lines.length) return null; + + const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)}; + const direction = ${direction ? JSON.stringify(direction) : 'null'}; + + const line = (rowIdx != null && lines[rowIdx]) + || lines.find(ln => { + const imgBox = ln.querySelector('.gridBoxImg'); + return !imgBox?.querySelector('.gridListH, .gridListV'); + }) + || lines[0]; + if (!line) return null; + + let candidates; + if (direction) { + // Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen. + const gridBox = grid.getBoundingClientRect(); + let scrollableLeft = gridBox.x; + [...line.children].forEach(b => { + if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) { + const br = b.getBoundingClientRect(); + if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width; + } + }); + const visible = [...line.children] + .filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix')) + .map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') })) + .filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width)); + if (!visible.length) return null; + visible.sort((a, b) => a.r.x - b.r.x); + candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible; + } else { + // Generic focus mode: any visible cell past the first column (tree toggles). + const cells = [...line.children] + .filter(b => b.offsetWidth > 0) + .map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') })); + if (!cells.length) return null; + candidates = cells.length > 1 ? cells.slice(1) : cells; + } + const pick = candidates.find(v => !v.checkbox) || candidates[0]; + if (!pick) return null; + return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) }; + })()`; +} + +/** + * Snapshot grid state for reveal-loop end detection. + * Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`. + * + * `firstText`/`lastText` use the first cell's `.gridBoxText` content. + * `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight. + */ +export function snapshotGridScript(gridSelector) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const lines = body.querySelectorAll('.gridLine'); + const txt = ln => ln?.querySelector('.gridBoxText')?.innerText?.trim() || ''; + const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select')); + const vsId = 'vertScroll_' + (grid.id || '').replace(/^form\\d+_/, ''); + const vs = grid.querySelector('#' + CSS.escape(vsId)); + let hasBelow; + if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) { + hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0; + } else { + hasBelow = body.scrollHeight > body.clientHeight; + } + return { + firstText: txt(lines[0]), + lastText: txt(lines[lines.length - 1]), + lineCount: lines.length, + selIdx, + hasBelow + }; + })()`; +} + +/** + * Resolve the click target kind for `clickElement({row, column})`. + * + * Routing: + * - `tableName` specified: try to match a visible grid by name (exact → contains). + * If matched → grid. Else if form has a spreadsheet iframe → spreadsheet. Else error. + * - `tableName` omitted: spreadsheet iframe present → spreadsheet (backward-compat). + * Else first visible grid. Else error. + * + * Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`. + */ +export function resolveCellTargetScript(formNum, tableName) { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const tableName = ${JSON.stringify(tableName || '')}; + // Spreadsheet = iframe under form prefix with non-trivial width. + const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => { + 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(p)) return true; + } + return false; + }); + const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .filter(g => g.offsetWidth > 0 && g.offsetHeight > 0); + const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase(); + + if (tableName) { + const target = norm(tableName); + const matched = grids.find(g => norm(g.id.replace(p, '')) === target) + || grids.find(g => norm(g.id.replace(p, '')).includes(target)); + if (matched) { + return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') }; + } + if (hasSpreadsheet) return { kind: 'spreadsheet' }; + return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) }; + } + if (hasSpreadsheet) return { kind: 'spreadsheet' }; + if (grids.length > 0) { + const g = grids[0]; + return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') }; + } + return { error: 'no_spreadsheet_or_grid' }; + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index 149c403b..915ec9a0 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -1,10 +1,10 @@ -// web-test core/click v1.20 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element handlers by target kind. +// web-test core/click v1.21 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element handlers by target kind. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected, highlightMode } from './state.mjs'; import { detectFormScript, findClickTargetScript, resolveGridScript, - readSubmenuScript, + readSubmenuScript, resolveCellTargetScript, } from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from './errors.mjs'; import { waitForStable } from './wait.mjs'; @@ -13,6 +13,7 @@ import { modifierClick, returnFormState } from './helpers.mjs'; import { clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget, } from '../table/click-row.mjs'; +import { clickGridCell } from '../table/click-cell.mjs'; import { clickConfirmationButton, tryClickPopupItem, } from '../forms/click-popup.mjs'; @@ -22,14 +23,36 @@ import { } from '../spreadsheet/spreadsheet.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. */ -export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) { + * First argument can also be an object { row, column } to click a cell in a SpreadsheetDocument (отчёт) or a form grid (таблица/табчасть). */ +export async function clickElement(text, { dblclick, table, toggle, expand, modifier, scroll, timeout } = {}) { ensureConnected(); - // Dispatch to spreadsheet cell handler when first arg is { row, column } + // Dispatch to cell handler when first arg is { row, column }. + // Routing (see resolveCellTargetScript): + // - `table` named: matches grid → grid cell; falls back to spreadsheet if it's the spreadsheet's name. + // - no `table`: form has spreadsheet → spreadsheet cell (backward-compat); + // else first visible grid → grid cell. if (typeof text === 'object' && text !== null && text.column != null) { await dismissPendingErrors(); - return clickSpreadsheetCell(text, { dblclick, modifier }); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('clickElement: no form found'); + const route = await page.evaluate(resolveCellTargetScript(formNum, table)); + if (route.error === 'table_not_found') { + throw new Error(`clickElement: table "${table}" not found. Available grids: ${(route.availableGrids || []).join(', ') || 'none'}`); + } + if (route.error) { + throw new Error(`clickElement: no spreadsheet or grid on form to click cell in.`); + } + if (route.kind === 'spreadsheet') { + return clickSpreadsheetCell(text, { dblclick, modifier }); + } + // route.kind === 'grid' + return clickGridCell(text, { + formNum, + gridSelector: route.gridSelector, + gridName: route.gridName, + modifier, dblclick, scroll, + }); } await dismissPendingErrors(); diff --git a/.claude/skills/web-test/scripts/engine/core/scroll-horiz.mjs b/.claude/skills/web-test/scripts/engine/core/scroll-horiz.mjs new file mode 100644 index 00000000..befb9794 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/core/scroll-horiz.mjs @@ -0,0 +1,47 @@ +// web-test core/scroll-horiz v1.0 — horizontal scroll loop helper for grids and spreadsheets. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// 1С scrolls horizontally by shifting absolute X coordinates of cells (not via +// scrollLeft). The only reliable way to drive this from outside is to press +// ArrowRight / ArrowLeft on a focused cell. Both SpreadsheetDocument and form +// grids share this mechanic, so the loop body is identical: press an arrow, +// wait, check visibility, bail when the cell stops moving (lost focus / hit edge). +// +// Callers handle their own focus setup (clicking a visible cell to put keyboard +// focus on the grid/spreadsheet), direction selection, and visibility queries. + +/** + * Press {direction} key in a loop until the target cell is fully visible or + * progress stalls. + * + * @param {object} opts + * @param {import('playwright').Page} opts.page + * @param {'ArrowRight'|'ArrowLeft'} opts.direction + * @param {() => Promise} opts.isFullyVisible — true when target inside viewport + * @param {() => Promise} opts.getCenterX — current target center X (page coords); null if cell vanished + * @param {number} [opts.maxPresses=100] + * @param {number} [opts.staleMax=5] — bail when center hasn't moved this many presses in a row + * @param {number} [opts.delayMs=50] — wait after each key press + * @param {number} [opts.finalDelayMs=200] — wait after the loop completes + */ +export async function scrollHorizontallyByKey({ + page, direction, + isFullyVisible, getCenterX, + maxPresses = 100, staleMax = 5, + delayMs = 50, finalDelayMs = 200, +}) { + let prevCx = await getCenterX(); + if (prevCx == null) return; + let stale = 0; + for (let i = 0; i < maxPresses; i++) { + await page.keyboard.press(direction); + await page.waitForTimeout(delayMs); + if (await isFullyVisible()) break; + const cx = await getCenterX(); + if (cx == null) break; + if (Math.abs(cx - prevCx) >= 1) stale = 0; + else { stale++; if (stale >= staleMax) break; } + prevCx = cx; + } + await page.waitForTimeout(finalDelayMs); +} diff --git a/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs b/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs index 2ee1aedc..1bc154aa 100644 --- a/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs @@ -1,4 +1,4 @@ -// web-test spreadsheet v1.18 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). +// web-test spreadsheet v1.19 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected } from '../core/state.mjs'; @@ -6,6 +6,7 @@ import { detectFormScript } from '../../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; import { getFormState } from '../forms/state.mjs'; import { returnFormState } from '../core/helpers.mjs'; +import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs'; // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- @@ -332,26 +333,17 @@ async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { } 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); + await scrollHorizontallyByKey({ + page, direction, + isFullyVisible: async () => { + const b = await getBox(); + return !!b && isFullyVisible(b); + }, + getCenterX: async () => { + const b = await getBox(); + return b ? b.x + b.width / 2 : null; + }, + }); } /** diff --git a/.claude/skills/web-test/scripts/engine/table/click-cell.mjs b/.claude/skills/web-test/scripts/engine/table/click-cell.mjs new file mode 100644 index 00000000..005d0065 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/table/click-cell.mjs @@ -0,0 +1,171 @@ +// web-test table/click-cell v1.1 — click a cell in a form grid by (row, column). +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Routed from core/click.mjs when the user calls clickElement({row, column}) and +// the form has no SpreadsheetDocument (or `table` matches a grid). +// +// Key behaviors: +// - `row` can be a number (index in current DOM window) or `{col: value}` filter. +// - `scroll: true | number` enables reveal-loop via PageDown when a filter row +// isn't visible. End detected by snapshot stability between PageDowns. +// - Horizontal scroll mirrors SpreadsheetDocument: focus a visible cell in the +// target row, press ArrowRight/Left until the target column is in viewport. +// +// 1С virtualization quirks worth knowing: +// - DOM holds a window of ~N visible rows. PageDown's first press moves the +// cursor inside the window; subsequent presses swap the window contents. +// - scrollTop/scrollLeft are always 0; absolute X of cells shifts on horizontal +// scroll. So scroll progress must be inferred from cell coordinates / snapshot +// diffs, never from scrollTop/Height. +// - Frozen columns (.gridBoxFix) stay pinned at the left, overlap with scrolled +// cells — DOM scripts handle the partition; engine just consumes their results. + +import { page } from '../core/state.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { modifierClick, returnFormState } from '../core/helpers.mjs'; +import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs'; +import { + findGridCellScript, findFocusCellScript, snapshotGridScript, +} from '../../dom.mjs'; + +const REVEAL_DEFAULT_LIMIT = 50; +const PD_WAIT_MS = 300; +const FOCUS_WAIT_MS = 150; + +/** + * Click a cell in a form grid by (row, column). Called from core/click.mjs. + * + * @param {object} target - { row: number|{col:value}, column: string } + * @param {object} ctx + * @param {number} ctx.formNum + * @param {string} ctx.gridSelector - CSS selector for the target grid + * @param {string} [ctx.gridName] - for diagnostics + * @param {string} [ctx.modifier] - 'ctrl' | 'shift' for multi-select + * @param {boolean} [ctx.dblclick] + * @param {boolean|number} [ctx.scroll] - true = up to 50 PageDowns, number = exact limit + */ +export async function clickGridCell(target, ctx) { + const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx; + + // 1. Try to find the cell in current DOM window. + let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target)); + + // 2. Reveal loop: only for filter-based row search with scroll opt-in. + if (cell?.error === 'row_not_found' && scroll && target.row && typeof target.row === 'object') { + cell = await revealAndFindCell({ formNum, gridSelector, target, scroll }); + } + + if (cell?.error) throw cellError(cell, target, gridName, scroll); + + // 3. Horizontal scroll if cell is off-viewport. + if (!cell.visible) { + await scrollGridToCell({ formNum, gridSelector, target, cell }); + cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target)); + if (cell?.error) { + throw new Error(`clickElement: cell vanished after horizontal scroll: ${cell.error}`); + } + if (!cell.visible) { + // Scroll loop bailed out before reaching the target. Don't silently click + // at off-screen coordinates — that would report a false success. + const ctxMsg = gridName ? ` in table "${gridName}"` : ''; + throw new Error(`clickElement: horizontal scroll could not reach column "${cell.columnText}"${ctxMsg} (cell still at x=${cell.cellX}, viewport ends at ${cell.gridRight}).`); + } + } + + // 4. Click. + await modifierClick(cell.x, cell.y, modifier, { dbl: !!dblclick }); + await waitForStable(); + return returnFormState({ + clicked: { + kind: 'gridCell', + row: target.row, + column: cell.columnText, + ...(dblclick ? { dblclick: true } : {}), + ...(modifier ? { modifier } : {}), + }, + }); +} + +function cellError(cell, target, gridName, scroll) { + const ctxMsg = gridName ? ` in table "${gridName}"` : ''; + if (cell.error === 'row_not_found') { + const hint = scroll + ? ' (reveal-loop exhausted)' + : ' — pass { scroll: true } to scan beyond the current DOM window'; + return new Error(`clickElement: row matching ${JSON.stringify(target.row)} not found${ctxMsg}${hint}.`); + } + if (cell.error === 'column_not_found' || cell.error === 'filter_column_not_found') { + return new Error(`clickElement: column "${cell.column}" not found${ctxMsg}. Available: ${(cell.available || []).join(', ')}`); + } + if (cell.error === 'row_out_of_range') { + return new Error(`clickElement: row index ${cell.row} out of range${ctxMsg} (loaded: ${cell.loaded}). Note: row index is into current DOM window, not absolute — long lists are virtualized.`); + } + return new Error(`clickElement: cannot resolve cell ${JSON.stringify(target)}${ctxMsg}: ${cell.error}`); +} + +/** + * Press PageDown in a loop, scanning DOM each iteration for the target row. + * Bail when the row is found, snapshots stop changing (end of list), or limit hit. + * page.mouse.click on a safe cell first — PageDown needs keyboard focus on gridBody. + */ +async function revealAndFindCell({ formNum, gridSelector, target, scroll }) { + const limit = typeof scroll === 'number' ? scroll : REVEAL_DEFAULT_LIMIT; + + const focusPt = await page.evaluate(findFocusCellScript(gridSelector)); + if (!focusPt) return { error: 'no_focusable_cell' }; + await page.mouse.click(focusPt.x, focusPt.y); + await page.waitForTimeout(FOCUS_WAIT_MS); + + let prevSnap = await page.evaluate(snapshotGridScript(gridSelector)); + for (let i = 0; i < limit; i++) { + await page.keyboard.press('PageDown'); + await page.waitForTimeout(PD_WAIT_MS); + + const cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target)); + if (!cell?.error) return cell; + + const snap = await page.evaluate(snapshotGridScript(gridSelector)); + const stable = snap + && snap.firstText === prevSnap?.firstText + && snap.lastText === prevSnap?.lastText + && snap.selIdx === prevSnap?.selIdx + && snap.lineCount === prevSnap?.lineCount; + if (stable) return { error: 'row_not_found', filter: target.row }; + prevSnap = snap; + } + return { error: 'row_not_found', filter: target.row }; +} + +/** + * Scroll the grid horizontally so the target cell falls inside the viewport. + * Focuses an edge cell in the target row (rightmost-visible for ArrowRight, + * leftmost-visible for ArrowLeft) so the next arrow key immediately scrolls. + * + * Frozen columns (gridBoxFix) are excluded from focus candidates — they don't + * drive the scrollable viewport. The DOM script handles that detail. + */ +async function scrollGridToCell({ formNum, gridSelector, target, cell }) { + const direction = cell.cellX > cell.gridRight ? 'ArrowRight' + : cell.cellRight < cell.gridX ? 'ArrowLeft' + : (cell.cellRight > cell.gridRight ? 'ArrowRight' : 'ArrowLeft'); + + const focusPt = await page.evaluate( + findFocusCellScript(gridSelector, { rowIdx: cell.rowIdx, direction }) + ); + if (!focusPt) throw new Error('clickElement: no visible cell to focus for horizontal scroll'); + await page.mouse.click(focusPt.x, focusPt.y); + await page.waitForTimeout(FOCUS_WAIT_MS); + + await scrollHorizontallyByKey({ + page, + direction, + isFullyVisible: async () => { + const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target)); + return !!c && !c.error && c.visible; + }, + getCenterX: async () => { + const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target)); + return c && !c.error ? c.x : null; + }, + }); +} diff --git a/tests/web-test/05-table.test.mjs b/tests/web-test/05-table.test.mjs index 8368cfa2..c7febb1b 100644 --- a/tests/web-test/05-table.test.mjs +++ b/tests/web-test/05-table.test.mjs @@ -20,10 +20,17 @@ export default async function({ navigateSection, openCommand, clickElement, fill ); const t = await readTable({ table: 'Товары' }); - log(`rows after add: ${t.rows?.length}`); + log(`rows after add: ${t.rows?.length}, hasMore: ${JSON.stringify(t.hasMore)}`); assert.equal(t.rows?.length, 2, 'Должно быть 2 строки'); assert.equal(t.rows[0]['Номенклатура'], 'Товар 01', 'Строка 0 = Товар 01'); assert.equal(t.rows[1]['Номенклатура'], 'Товар 02', 'Строка 1 = Товар 02'); + // hasMore: две строки точно помещаются в табчасть — both false + assert.ok(t.hasMore, 'hasMore должен быть в результате readTable'); + assert.equal(t.hasMore.below, false, 'hasMore.below должно быть false (всё видно)'); + // above либо false (видимый scrollbar), либо undefined (дин-список) — но для табчасти ждём false + if (t.hasMore.above !== undefined) { + assert.equal(t.hasMore.above, false, 'hasMore.above должно быть false (мы на первой странице)'); + } }); await step('edit: изменить количество в строке 0 через fillTableRow row:0', async () => { diff --git a/tests/web-test/18-cell-click.test.mjs b/tests/web-test/18-cell-click.test.mjs new file mode 100644 index 00000000..1c2bd3f4 --- /dev/null +++ b/tests/web-test/18-cell-click.test.mjs @@ -0,0 +1,129 @@ +export const name = 'clickElement({row, column}): cell click on grids + spreadsheet backward-compat'; +export const tags = ['cell-click', 'smoke']; +export const timeout = 120000; + +export default async function({ + navigateSection, navigateLink, openCommand, clickElement, fillFields, fillTableRow, + readTable, readSpreadsheet, closeForm, getFormState, wait, assert, step, log +}) { + + // ── Spreadsheet backward-compat ───────────────────────────────────────────── + await step('spreadsheet: cell click by (row, column) still works (regression guard)', async () => { + await navigateSection('Склад'); + await openCommand('Остатки товаров'); + await clickElement('Еще'); + await clickElement('Установить стандартные настройки'); + await clickElement('Сформировать'); + await wait(3); + const r = await readSpreadsheet(); + assert.ok(r.data?.length > 0, 'В отчёте есть данные'); + const firstHeader = r.headers[0]; + const before = await getFormState(); + const res = await clickElement({ row: 0, column: firstHeader }); + log(`spreadsheet click: ${JSON.stringify(res.clicked)}`); + assert.equal(res.clicked?.kind, 'spreadsheetCell', 'kind=spreadsheetCell — без table роутер ушёл в spreadsheet'); + await closeForm(); + }); + + // ── Grid cell click: catalog list with dblclick to open item ──────────────── + await step('catalog list: dblclick by {row: filter, column} opens the item', async () => { + await navigateSection('Склад'); + await openCommand('Контрагенты'); + const t = await readTable(); + assert.ok(t.rows?.length > 0, 'Список Контрагентов не пуст'); + // Используем фикстуру стенда: ООО Север в колонке Наименование + const before = await getFormState(); + const res = await clickElement( + { row: { 'Наименование': 'ООО Север' }, column: 'Наименование' }, + { dblclick: true } + ); + log(`clicked: ${JSON.stringify(res.clicked)}`); + assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell'); + assert.equal(res.clicked?.dblclick, true, 'dblclick=true прокинут'); + await wait(1); + const after = await getFormState(); + // На синтетическом стенде поведение dblclick по ячейке может не открывать форму, + // если колонка не "главная" — главное, что клик завершился без ошибки и тип события правильный. + if (after.formCount > before.formCount) { + log('форма открылась — закрываем'); + await closeForm(); + } + }); + + // ── Grid cell click on tabular section + row by numeric index ────────────── + await step('tabular section: click cell by row:0 + column (table specified)', async () => { + await navigateSection('Склад'); + await openCommand('Приходная накладная'); + await clickElement('Создать'); + await fillFields({ 'Контрагент': 'ООО Север' }); + await fillTableRow( + { 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' }, + { table: 'Товары', add: true } + ); + await fillTableRow( + { 'Номенклатура': 'Товар 02', 'Количество': '3', 'Цена': '200' }, + { table: 'Товары', add: true } + ); + const res = await clickElement( + { row: 0, column: 'Количество' }, + { table: 'Товары' } + ); + log(`clicked: ${JSON.stringify(res.clicked)}`); + assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell'); + assert.equal(res.clicked?.row, 0, 'row=0 сохранён в результате'); + assert.equal(res.clicked?.column, 'Количество', 'column=Количество'); + }); + + // ── readTable.hasMore on tabular section ─────────────────────────────────── + await step('readTable.hasMore: 2-row table shows hasMore.below=false', async () => { + const t = await readTable({ table: 'Товары' }); + log(`hasMore: ${JSON.stringify(t.hasMore)}`); + assert.ok(t.hasMore, 'hasMore присутствует в результате'); + assert.equal(t.hasMore.below, false, 'hasMore.below=false для двух строк (всё видно)'); + }); + + // ── Error path: row not in DOM, no scroll → understandable error ─────────── + await step('row_not_found без scroll бросает ошибку с подсказкой', async () => { + let caught = null; + try { + await clickElement( + { row: { 'Количество': 'НЕСУЩЕСТВУЮЩЕЕ_ЗНАЧЕНИЕ_123' }, column: 'Количество' }, + { table: 'Товары' } // без scroll + ); + } catch (e) { + caught = e; + } + assert.ok(caught, 'Должна быть ошибка'); + log(`error: ${caught.message}`); + assert.ok(/not found/i.test(caught.message), 'Сообщение упоминает not found'); + assert.ok(/scroll/i.test(caught.message), 'Сообщение содержит подсказку про scroll: true'); + }); + + // ── Error path: out of range numeric row ─────────────────────────────────── + await step('row_out_of_range на числовом индексе бросает понятную ошибку', async () => { + let caught = null; + try { + await clickElement( + { row: 9999, column: 'Количество' }, + { table: 'Товары' } + ); + } catch (e) { + caught = e; + } + assert.ok(caught, 'Должна быть ошибка'); + log(`error: ${caught.message}`); + assert.ok(/out of range/i.test(caught.message), 'Сообщение упоминает out of range'); + assert.ok(/virtualized/i.test(caught.message) || /DOM window/i.test(caught.message), + 'Сообщение объясняет про виртуализацию / DOM window'); + }); + + // ── Cleanup ──────────────────────────────────────────────────────────────── + await step('cleanup: close document', async () => { + await closeForm({ save: false }); + }); + + // Note: reveal-loop (scroll:true) algorithm verified manually on bp-demo + // (catalog Контрагенты, group Покупатели, ~22 items requiring page-down). + // The synthetic stand has issues with rapid sequential doc opens that prevent + // a stable >30-row table setup here — left for a future enhancement of _hooks. +}