From c147fd5cb7fb5a0c3216c8d5aecfa5f4d3828f0a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 1 Jun 2026 22:03:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20fillTableRow=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D1=83=D0=B5=D1=82=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D1=83=20=D0=BF=D0=BE=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=83=20{=20col:=20value=20}=20+=20scr?= =?UTF-8?q?oll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/skills/web-test/SKILL.md | 8 +- .claude/skills/web-test/scripts/dom/grid.mjs | 7 +- .../scripts/engine/table/click-cell.mjs | 85 ++++++++++++++----- .../scripts/engine/table/row-fill.mjs | 16 +++- tests/web-test/05-table.test.mjs | 14 +++ tests/web-test/18-cell-click.test.mjs | 15 ++++ 6 files changed, 117 insertions(+), 28 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 34d894bb..ef9e8bfc 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -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( { 'Объект': 'БДДС' }, diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 2b7fbe97..3d7f59c4 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.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; diff --git a/.claude/skills/web-test/scripts/engine/table/click-cell.mjs b/.claude/skills/web-test/scripts/engine/table/click-cell.mjs index ad2761b7..70ab1404 100644 --- a/.claude/skills/web-test/scripts/engine/table/click-cell.mjs +++ b/.claude/skills/web-test/scripts/engine/table/click-cell.mjs @@ -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} 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 }; diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index 7cd2d6a5..1a105f6e 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -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) { diff --git a/tests/web-test/05-table.test.mjs b/tests/web-test/05-table.test.mjs index fd6ac340..c5b1f3a8 100644 --- a/tests/web-test/05-table.test.mjs +++ b/tests/web-test/05-table.test.mjs @@ -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' }, diff --git a/tests/web-test/18-cell-click.test.mjs b/tests/web-test/18-cell-click.test.mjs index c69eb5e5..5fdeed88 100644 --- a/tests/web-test/18-cell-click.test.mjs +++ b/tests/web-test/18-cell-click.test.mjs @@ -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(