feat(web-test): clickElement({row,column}) для гридов формы + readTable.hasMore

clickElement({row,column}) теперь работает не только на SpreadsheetDocument,
но и на гридах формы (динамические списки, табчасти). Маршрутизация:
spreadsheet приоритет (backward-compat), без spreadsheet — первый видимый
грид; явный table='Имя' форсит конкретный грид.

Поддержка:
- row: number — индекс в текущем DOM окне (виртуализация — документировано)
- row: { Колонка: значение } — фильтр по нормализованному содержимому
- scroll: true | number — reveal-loop через PageDown пока строка не найдена
  или DOM не перестал меняться (с лимитом)
- Автоматический горизонтальный скролл к колонке за viewport
  (учитывает frozen-колонки .gridBoxFix)
- Post-scroll visibility check — throw вместо ложного success

readTable обогащён полем hasMore: { above?, below } — единственный
надёжный сигнал виртуализации. total/shown остаются как DOM-окно
(backward-compat) с честным описанием в SKILL.md.

Общий хелпер scrollHorizontallyByKey вынесен в engine/core/, переиспользуется
spreadsheet'ом и грид-click'ом. DOM-логика (findGridCellScript,
findFocusCellScript, snapshotGridScript, resolveCellTargetScript) живёт
в dom/grid.mjs — engine только оркестрирует.

Покрытие: новый 18-cell-click.test.mjs (7 шагов: spreadsheet
regression-guard, catalog dblclick, табчасть, hasMore, 2 error-paths,
cleanup). Расширен 05-table.test.mjs проверкой hasMore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-28 19:01:11 +03:00
parent dff3ced847
commit e05c0a4a61
9 changed files with 746 additions and 46 deletions
@@ -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();
@@ -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<boolean>} opts.isFullyVisible — true when target inside viewport
* @param {() => Promise<number|null>} 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);
}
@@ -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;
},
});
}
/**
@@ -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;
},
});
}