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
@@ -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;
},
});
}