feat(web-test): fillTableRow редактирует строку по фильтру { col: value } + scroll

fillTableRow теперь принимает row как объектный фильтр (одна/несколько колонок,
AND-матч) — как clickElement — и опцию scroll:true для строк за пределами
DOM-окна виртуализации. Фильтр резолвится в числовой индекс один раз в начале
через переиспользование resolveRowIndexByFilter из click-cell.mjs (без дублей
matching/reveal); дальше существующий код row-mode не тронут. row:<число> —
полная обратная совместимость.

Побочно починен баг в общем reveal-цикле (его же использует clickElement scroll):
детектор конца списка опирался на текст первой колонки + selIdx, поэтому на
табчасти с однотипной первой колонкой ложно срабатывал на втором PageDown.
Теперь основной признак конца — hasBelow===false, а сигнатура снимка строится
по всей строке (snapshotGridScript).

Версии: click-cell v1.4, dom/grid v1.9, row-fill v1.22.
Регресс tests/web-test: 22/22 зелёные (live E2E на синтетическом стенде).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-01 22:03:06 +03:00
parent ffb380187f
commit c147fd5cb7
6 changed files with 117 additions and 28 deletions
+6 -2
View File
@@ -351,7 +351,8 @@ Returns form state with `filled: [{ field, ok, ...}]`. Items are `{ field, ok: t
|--------|-------------|
| `tab` | Switch to tab before filling |
| `add` | Add new row before filling |
| `row` | Edit existing row by 0-based index |
| `row` | Edit existing row: 0-based index, **or** a `{ col: value }` filter (one or more columns) to locate the row by its cell values |
| `scroll` | With a `row` filter — scan beyond the current DOM window (`true` = up to 50 PageDowns, number = limit) |
| `table` | Grid name from `tables[]` (for multi-grid forms) |
```js
@@ -360,11 +361,14 @@ await fillTableRow(
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
{ tab: 'Товары', add: true }
);
// Edit existing row:
// Edit existing row by index:
await fillTableRow(
{ 'Количество': '20' },
{ tab: 'Товары', row: 0 }
);
// Edit existing row located by cell values (одна или несколько колонок):
await fillTableRow({ 'Цена': '120' }, { table: 'Товары', row: { 'Номенклатура': 'Бумага' } });
await fillTableRow({ 'Сумма': '500' }, { row: { 'Номер': '0000-000601', 'Дата': '29.12.2016' }, scroll: true });
// Multi-grid form — add row to specific table:
await fillTableRow(
{ 'Объект': 'БДДС' },
+5 -2
View File
@@ -1,4 +1,4 @@
// web-test dom/grid v1.8 — grid resolution + table reading + edit-time helpers
// web-test dom/grid v1.9 — grid resolution + table reading + edit-time helpers
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
@@ -677,7 +677,10 @@ export function snapshotGridScript(gridSelector) {
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = body.querySelectorAll('.gridLine');
const txt = ln => ln?.querySelector('.gridBoxText')?.innerText?.trim() || '';
// Full-row signature: join EVERY cell's text, not just the first column.
// A low-cardinality first column (e.g. all "Товар 0X") would otherwise make
// two different windows look identical and abort the reveal-loop early.
const txt = ln => ln ? [...ln.querySelectorAll('.gridBoxText')].map(b => (b.innerText || '').trim()).join('|') : '';
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
let hasBelow;
@@ -1,4 +1,4 @@
// web-test table/click-cell v1.3 — click a cell in a form grid by (row, column).
// web-test table/click-cell v1.4 — 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
@@ -32,6 +32,48 @@ const REVEAL_DEFAULT_LIMIT = 50;
const PD_WAIT_MS = 300;
const FOCUS_WAIT_MS = 150;
/**
* Guard: a 'pic:N' filter value is a readTable picture token, not real cell text.
* Picture cells render an icon (no text), so they can't select a row — fail fast
* with guidance instead of a confusing 'row_not_found'.
*/
function assertNotPictureFilter(filter) {
for (const [k, v] of Object.entries(filter)) {
if (typeof v === 'string' && /^pic:\d+$/.test(v.trim())) {
throw new Error(`clickElement: "${v}" is a readTable picture value (column "${k}"), not selectable text — it can't be used as a row filter. Filter by a text column (e.g. name/number) instead.`);
}
}
}
/**
* Resolve a `{ col: value }` row filter to a numeric index into the grid's current
* DOM window (`body.querySelectorAll('.gridLine')`). Reused by fillTableRow so it
* can target an existing row by cell values, mirroring clickElement.
*
* The filter matches across ALL columns (AND). `findGridCellScript` requires a
* `column`, so we pass the first filter key as a placeholder — it only affects the
* returned coordinates (which we ignore), not row selection. The matched row
* guarantees that key's cell is in the DOM, so no `cell_not_in_dom` for it.
*
* @param {object} args
* @param {number} args.formNum
* @param {string} [args.gridSelector] - CSS selector for the target grid (same grid the caller edits)
* @param {object} args.filter - `{ col: value }` (one or more columns)
* @param {string} [args.gridName] - for diagnostics in error messages
* @param {boolean|number} [args.scroll] - reveal-loop beyond the DOM window (true = 50 PageDowns, number = limit)
* @returns {Promise<number>} resolved row index
*/
export async function resolveRowIndexByFilter({ formNum, gridSelector, filter, gridName, scroll }) {
assertNotPictureFilter(filter);
const target = { row: filter, column: Object.keys(filter)[0] };
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
if (cell?.error === 'row_not_found' && scroll) {
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
}
if (cell?.error) throw cellError(cell, target, gridName, scroll, 'fillTableRow');
return cell.rowIdx;
}
/**
* Click a cell in a form grid by (row, column). Called from core/click.mjs.
*
@@ -47,16 +89,7 @@ const FOCUS_WAIT_MS = 150;
export async function clickGridCell(target, ctx) {
const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx;
// Guard: a 'pic:N' filter value is a readTable picture token, not real cell text.
// Picture cells render an icon (no text), so they can't select a row — fail fast
// with guidance instead of a confusing 'row_not_found'.
if (target?.row && typeof target.row === 'object') {
for (const [k, v] of Object.entries(target.row)) {
if (typeof v === 'string' && /^pic:\d+$/.test(v.trim())) {
throw new Error(`clickElement: "${v}" is a readTable picture value (column "${k}"), not selectable text — it can't be used as a row filter. Filter by a text column (e.g. name/number) instead.`);
}
}
}
if (target?.row && typeof target.row === 'object') assertNotPictureFilter(target.row);
// 1. Try to find the cell in current DOM window.
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
@@ -97,21 +130,21 @@ export async function clickGridCell(target, ctx) {
});
}
function cellError(cell, target, gridName, scroll) {
function cellError(cell, target, gridName, scroll, who = 'clickElement') {
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}.`);
return new Error(`${who}: 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(', ')}`);
return new Error(`${who}: 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(`${who}: 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}`);
return new Error(`${who}: cannot resolve cell ${JSON.stringify(target)}${ctxMsg}: ${cell.error}`);
}
/**
@@ -142,12 +175,20 @@ async function revealAndFindCell({ formNum, gridSelector, target, scroll }) {
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 };
// Reached the end of the list. Primary signal: nothing remains below
// (`hasBelow === false`) — the reliable cross-grid-type signal. Content
// stability is only a fallback when hasBelow is unknown: it compares the
// full-row text (snapshotGridScript joins every cell), so a low-cardinality
// first column (e.g. all "Товар 0X") can't look "stable" mid-scroll.
const reachedEnd = snap && (
snap.hasBelow === false
|| (snap.hasBelow == null
&& snap.firstText === prevSnap?.firstText
&& snap.lastText === prevSnap?.lastText
&& snap.selIdx === prevSnap?.selIdx
&& snap.lineCount === prevSnap?.lineCount)
);
if (reachedEnd) return { error: 'row_not_found', filter: target.row };
prevSnap = snap;
}
return { error: 'row_not_found', filter: target.row };
@@ -1,4 +1,4 @@
// web-test table/row-fill v1.21 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
// web-test table/row-fill v1.22 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
@@ -24,6 +24,7 @@ import {
readEdd, isEddVisible, clickEddItemViaDispatch,
} from '../core/helpers.mjs';
import { clickElement } from '../core/click.mjs';
import { resolveRowIndexByFilter } from './click-cell.mjs';
import {
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
fillReferenceField, selectValue,
@@ -111,9 +112,13 @@ async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } =
* @param {Object} [options]
* @param {string} [options.tab] - Switch to this form tab before operating
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
* @param {number|Object} [options.row] - Edit existing row: 0-based DOM-window index, or
* a `{ col: value }` filter (one or more columns, AND-matched) to locate the row by cell values
* @param {boolean|number} [options.scroll] - When `row` is a filter, scan beyond the current
* DOM window via PageDown (true = up to 50 presses, number = exact limit)
* @returns {{ filled[], notFilled[]?, form }}
*/
export async function fillTableRow(fields, { tab, add, row, table } = {}) {
export async function fillTableRow(fields, { tab, add, row, table, scroll } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
@@ -133,6 +138,13 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
await clickElement(tab);
}
// 1b. Resolve a { col: value } row filter to a numeric DOM-window index (mirrors
// clickElement). After this, `row` is a number and all downstream code/recursion
// works unchanged. Filter targets an EXISTING row — incompatible with `add`.
if (row != null && typeof row === 'object') {
row = await resolveRowIndexByFilter({ formNum, gridSelector, filter: row, gridName: table, scroll });
}
// 2. Add new row if requested
let addedRowIdx = -1;
if (add) {
+14
View File
@@ -43,6 +43,20 @@ export default async function({ navigateSection, openCommand, clickElement, fill
assert.equal(t.rows[0]['Количество'], '10,000', 'Количество строки 0 = 10');
});
await step('edit by filter: найти строку по значению ячейки { Номенклатура: Товар 02 } и изменить Цену', async () => {
const r = await fillTableRow(
{ 'Цена': '250' },
{ table: 'Товары', row: { 'Номенклатура': 'Товар 02' } }
);
log(`filter-edit result: ${JSON.stringify(r.filled)}`);
const t = await readTable({ table: 'Товары' });
log(`rows after filter-edit: ${JSON.stringify(t.rows)}`);
// Должна измениться именно строка Товар 02 (индекс 1), а не Товар 01 (индекс 0).
assert.equal(t.rows[1]['Номенклатура'], 'Товар 02', 'Фильтр нашёл строку Товар 02');
assert.equal(t.rows[1]['Цена'], '250,00', 'Цена строки Товар 02 = 250');
assert.equal(t.rows[0]['Номенклатура'], 'Товар 01', 'Строка Товар 01 не тронута');
});
await step('tab-loop: изменить два числовых поля в строке 1 одним вызовом', async () => {
const r = await fillTableRow(
{ 'Количество': '7', 'Цена': '150' },
+15
View File
@@ -154,6 +154,21 @@ export default async function({
assert.equal(res.clicked?.column, 'Сумма', 'column сохранён');
});
// ── fillTableRow by filter + scroll: тот же reveal-путь, что у clickElement ──
await step('fillTableRow: row-фильтр + scroll:true редактирует глубокую строку LongDoc', async () => {
// Количество=28 заведомо за пределами стартового DOM-окна (LongDoc 1..30).
const r = await fillTableRow(
{ 'Цена': '888' },
{ table: 'Товары', row: { 'Количество': '28' }, scroll: true }
);
log(`filled: ${JSON.stringify(r.filled)}`);
assert.ok(r.filled?.every(f => f.ok), 'все ячейки заполнены без ошибок');
const t = await readTable({ table: 'Товары', maxRows: 50 });
const row28 = t.rows.find(x => x['Количество'] === '28,000');
assert.ok(row28, 'строка Количество=28 в текущем окне после reveal');
assert.equal(row28['Цена'], '888,00', 'Цена строки 28 изменена через фильтр+scroll');
});
// ── Horizontal scroll: вправо до последней колонки, потом обратно влево ────
await step('horizontal scroll: вправо до Признак контроля, потом влево к Количество', async () => {
const right = await clickElement(