mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-16 10:43:18 +03:00
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:
@@ -351,7 +351,8 @@ Returns form state with `filled: [{ field, ok, ...}]`. Items are `{ field, ok: t
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `tab` | Switch to tab before filling |
|
| `tab` | Switch to tab before filling |
|
||||||
| `add` | Add new row 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) |
|
| `table` | Grid name from `tables[]` (for multi-grid forms) |
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -360,11 +361,14 @@ await fillTableRow(
|
|||||||
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
|
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
|
||||||
{ tab: 'Товары', add: true }
|
{ tab: 'Товары', add: true }
|
||||||
);
|
);
|
||||||
// Edit existing row:
|
// Edit existing row by index:
|
||||||
await fillTableRow(
|
await fillTableRow(
|
||||||
{ 'Количество': '20' },
|
{ 'Количество': '20' },
|
||||||
{ tab: 'Товары', row: 0 }
|
{ 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:
|
// Multi-grid form — add row to specific table:
|
||||||
await fillTableRow(
|
await fillTableRow(
|
||||||
{ 'Объект': 'БДДС' },
|
{ 'Объект': 'БДДС' },
|
||||||
|
|||||||
@@ -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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -677,7 +677,10 @@ export function snapshotGridScript(gridSelector) {
|
|||||||
const body = grid.querySelector('.gridBody');
|
const body = grid.querySelector('.gridBody');
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
const lines = body.querySelectorAll('.gridLine');
|
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'));
|
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.
|
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
|
||||||
let hasBelow;
|
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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
//
|
//
|
||||||
// Routed from core/click.mjs when the user calls clickElement({row, column}) and
|
// 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 PD_WAIT_MS = 300;
|
||||||
const FOCUS_WAIT_MS = 150;
|
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.
|
* 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) {
|
export async function clickGridCell(target, ctx) {
|
||||||
const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx;
|
const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx;
|
||||||
|
|
||||||
// Guard: a 'pic:N' filter value is a readTable picture token, not real cell text.
|
if (target?.row && typeof target.row === 'object') assertNotPictureFilter(target.row);
|
||||||
// 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.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Try to find the cell in current DOM window.
|
// 1. Try to find the cell in current DOM window.
|
||||||
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
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}"` : '';
|
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
|
||||||
if (cell.error === 'row_not_found') {
|
if (cell.error === 'row_not_found') {
|
||||||
const hint = scroll
|
const hint = scroll
|
||||||
? ' (reveal-loop exhausted)'
|
? ' (reveal-loop exhausted)'
|
||||||
: ' — pass { scroll: true } to scan beyond the current DOM window';
|
: ' — 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') {
|
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') {
|
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;
|
if (!cell?.error) return cell;
|
||||||
|
|
||||||
const snap = await page.evaluate(snapshotGridScript(gridSelector));
|
const snap = await page.evaluate(snapshotGridScript(gridSelector));
|
||||||
const stable = snap
|
// Reached the end of the list. Primary signal: nothing remains below
|
||||||
&& snap.firstText === prevSnap?.firstText
|
// (`hasBelow === false`) — the reliable cross-grid-type signal. Content
|
||||||
&& snap.lastText === prevSnap?.lastText
|
// stability is only a fallback when hasBelow is unknown: it compares the
|
||||||
&& snap.selIdx === prevSnap?.selIdx
|
// full-row text (snapshotGridScript joins every cell), so a low-cardinality
|
||||||
&& snap.lineCount === prevSnap?.lineCount;
|
// first column (e.g. all "Товар 0X") can't look "stable" mid-scroll.
|
||||||
if (stable) return { error: 'row_not_found', filter: target.row };
|
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;
|
prevSnap = snap;
|
||||||
}
|
}
|
||||||
return { error: 'row_not_found', filter: target.row };
|
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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
readEdd, isEddVisible, clickEddItemViaDispatch,
|
readEdd, isEddVisible, clickEddItemViaDispatch,
|
||||||
} from '../core/helpers.mjs';
|
} from '../core/helpers.mjs';
|
||||||
import { clickElement } from '../core/click.mjs';
|
import { clickElement } from '../core/click.mjs';
|
||||||
|
import { resolveRowIndexByFilter } from './click-cell.mjs';
|
||||||
import {
|
import {
|
||||||
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
|
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
|
||||||
fillReferenceField, selectValue,
|
fillReferenceField, selectValue,
|
||||||
@@ -111,9 +112,13 @@ async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } =
|
|||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {string} [options.tab] - Switch to this form tab before operating
|
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||||
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
|
* @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 }}
|
* @returns {{ filled[], notFilled[]?, form }}
|
||||||
*/
|
*/
|
||||||
export async function fillTableRow(fields, { tab, add, row, table } = {}) {
|
export async function fillTableRow(fields, { tab, add, row, table, scroll } = {}) {
|
||||||
ensureConnected();
|
ensureConnected();
|
||||||
await dismissPendingErrors();
|
await dismissPendingErrors();
|
||||||
const formNum = await page.evaluate(detectFormScript());
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
@@ -133,6 +138,13 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
|
|||||||
await clickElement(tab);
|
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
|
// 2. Add new row if requested
|
||||||
let addedRowIdx = -1;
|
let addedRowIdx = -1;
|
||||||
if (add) {
|
if (add) {
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ export default async function({ navigateSection, openCommand, clickElement, fill
|
|||||||
assert.equal(t.rows[0]['Количество'], '10,000', 'Количество строки 0 = 10');
|
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 () => {
|
await step('tab-loop: изменить два числовых поля в строке 1 одним вызовом', async () => {
|
||||||
const r = await fillTableRow(
|
const r = await fillTableRow(
|
||||||
{ 'Количество': '7', 'Цена': '150' },
|
{ 'Количество': '7', 'Цена': '150' },
|
||||||
|
|||||||
@@ -154,6 +154,21 @@ export default async function({
|
|||||||
assert.equal(res.clicked?.column, 'Сумма', 'column сохранён');
|
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: вправо до последней колонки, потом обратно влево ────
|
// ── Horizontal scroll: вправо до последней колонки, потом обратно влево ────
|
||||||
await step('horizontal scroll: вправо до Признак контроля, потом влево к Количество', async () => {
|
await step('horizontal scroll: вправо до Признак контроля, потом влево к Количество', async () => {
|
||||||
const right = await clickElement(
|
const right = await clickElement(
|
||||||
|
|||||||
Reference in New Issue
Block a user