mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
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:
@@ -165,7 +165,7 @@ const form = await getFormState();
|
||||
|
||||
### Reading data
|
||||
|
||||
#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset }`
|
||||
#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset, hasMore }`
|
||||
Read actual grid data with pagination. Each row is `{ columnName: value }`.
|
||||
|
||||
| Option | Default | Description |
|
||||
@@ -183,10 +183,22 @@ Special row fields:
|
||||
- `hierarchical: true` — list has groups (on result object)
|
||||
- `viewMode: 'tree'` — tree view active (on result object)
|
||||
|
||||
**`total` is misleading for long lists.** 1С virtualizes both dynamic lists and form tabular sections — the DOM holds only a window of visible rows. `total` / `shown` count what's *loaded right now*, not the size of the underlying collection. Use **`hasMore`** to know if there's more data outside the window:
|
||||
|
||||
```js
|
||||
const t = await readTable();
|
||||
// t.hasMore = { above: false, below: true } ← form tabular section, scrollbar visible
|
||||
// t.hasMore = { below: true } ← dynamic list (catalog/journal/register)
|
||||
// t.hasMore = { below: false } ← everything visible / end of list reached
|
||||
```
|
||||
|
||||
- `hasMore.below` — always present. `true` ⇒ scrolling down (PageDown / `clickElement` with `scroll:true`) will reveal more rows.
|
||||
- `hasMore.above` — only present for tabular sections with a visible scrollbar widget. Dynamic lists hide their scrollbar so we cannot detect "above" reliably; treat absence as unknown.
|
||||
|
||||
```js
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
console.log('Columns:', t.columns);
|
||||
console.log('Rows:', t.rows.length, 'of', t.total);
|
||||
console.log('Loaded:', t.shown, 'rows; more below:', t.hasMore.below);
|
||||
// Pagination:
|
||||
const page2 = await readTable({ maxRows: 50, offset: 50 });
|
||||
```
|
||||
@@ -219,7 +231,7 @@ Sections + all open tabs.
|
||||
|
||||
**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`.
|
||||
|
||||
#### `clickElement(text, { dblclick?, table?, expand?, modifier? })` → form state
|
||||
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
|
||||
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
||||
|
||||
- `table` — scope button search to a specific grid's command panel (by name from `tables[]`):
|
||||
@@ -250,25 +262,31 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
||||
const t = await readTable();
|
||||
t.rows.filter(r => r._selected); // rows with _selected: true
|
||||
```
|
||||
- **SpreadsheetDocument cells** (report drill-down): first argument can be `{ row, column }` object to click a cell in a rendered report. Coordinates match `readSpreadsheet()` output:
|
||||
- **Cell click by (row, column)** — first argument as `{ row, column }`. Routes: spreadsheet on form → spreadsheet drill-down; otherwise → grid cell. Pass `table: 'GridName'` to force a specific grid when both are present.
|
||||
|
||||
Spreadsheet report drill-down:
|
||||
```js
|
||||
const report = await readSpreadsheet();
|
||||
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000', ... }
|
||||
|
||||
// By data row index + column header name
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
||||
|
||||
// By cell value filter (fuzzy match)
|
||||
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true });
|
||||
|
||||
// Totals row
|
||||
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true });
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); // by index
|
||||
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); // by filter
|
||||
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); // totals row
|
||||
await clickElement('150 000', { dblclick: true }); // fallback: by text
|
||||
```
|
||||
Text search also works as fallback — searches inside spreadsheet iframes:
|
||||
|
||||
Form grid cell (catalog list, journal, table part). Off-viewport columns auto-scroll horizontally (works around frozen columns). Use `scroll: true | number` for filter-based rows outside the current DOM window:
|
||||
```js
|
||||
await clickElement('150 000', { dblclick: true }); // finds cell by text in report
|
||||
await clickElement({ row: 0, column: 'Количество' }, { table: 'Товары', dblclick: true });
|
||||
await clickElement({ row: { 'Номенклатура': 'Бумага' }, column: 'Цена' }, { table: 'Товары' });
|
||||
await clickElement({ row: { 'Номер': '0000-000601' }, column: 'Сумма' },
|
||||
{ table: 'Реализации', scroll: true }); // PageDown loop, max 50
|
||||
```
|
||||
|
||||
Gotchas:
|
||||
- `row: <number>` is the index in the **current DOM window**, not absolute — 1С virtualizes long lists. `row: 0` is the topmost loaded row after any prior scroll. For arbitrary rows in a long list use `row: { col: val }` + `scroll: true`.
|
||||
- `scroll: true` walks **down only** (PageDown). For going up first press `Home` via `getPage().keyboard` or narrow with `filterList`.
|
||||
- First matching row wins on duplicate filter matches — refine the filter to disambiguate.
|
||||
|
||||
#### `fillFields({ name: value })` → form state with `filled`
|
||||
Fill form fields by label (fuzzy match). Auto-detects field type.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// web-test dom v1.14 — facade re-exporting injectable DOM scripts from dom/
|
||||
// web-test dom v1.16 — facade re-exporting injectable DOM scripts from dom/
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Facade: re-exports DOM selector & semantic mapping script generators.
|
||||
@@ -60,6 +60,10 @@ export {
|
||||
isTreeGridScript,
|
||||
findGridHeadCenterCoordsScript,
|
||||
getSelectedOrLastRowIndexScript,
|
||||
findGridCellScript,
|
||||
findFocusCellScript,
|
||||
snapshotGridScript,
|
||||
resolveCellTargetScript,
|
||||
} from './dom/grid.mjs';
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// web-test dom/grid v1.3 — grid resolution + table reading + edit-time helpers
|
||||
// web-test dom/grid v1.5 — grid resolution + table reading + edit-time helpers
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
@@ -241,7 +241,21 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
||||
}
|
||||
const isTree = !!body.querySelector('.gridBoxTree');
|
||||
const hasGroups = rows.some(r => r._kind === 'group');
|
||||
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length };
|
||||
// Virtualization-aware "has more" signal:
|
||||
// - Tabular sections render a visible scrollbar widget (#vertScroll_* with class "scrollV" and non-zero size).
|
||||
// Its child tracks expose exact above/below pixel offsets relative to the slider.
|
||||
// - Dynamic lists hide the widget (empty class, 0×0). We can only infer below via scrollHeight>clientHeight.
|
||||
let hasMore;
|
||||
const vsId = 'vertScroll_' + (grid.id || '').replace(p, '');
|
||||
const vs = grid.querySelector('#' + CSS.escape(vsId));
|
||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
|
||||
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
|
||||
hasMore = { above: back > 0, below: next > 0 };
|
||||
} else {
|
||||
hasMore = { below: body.scrollHeight > body.clientHeight };
|
||||
}
|
||||
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
||||
if (isTree) result.viewMode = 'tree';
|
||||
if (hasGroups) result.hierarchical = true;
|
||||
return result;
|
||||
@@ -381,3 +395,298 @@ export function scanGridRowsScript(formNum, searchLower) {
|
||||
return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup };
|
||||
})()`;
|
||||
}
|
||||
|
||||
// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ───────
|
||||
|
||||
/**
|
||||
* Resolve a target cell in a grid by (row, column).
|
||||
* - `column` matched: exact (case+ё-insensitive) → endsWith ' / X' → includes.
|
||||
* - `row`: number = index in current DOM window; object = {col: value, ...} filter
|
||||
* (matches first non-group/parent row where every column condition passes).
|
||||
*
|
||||
* Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`.
|
||||
*
|
||||
* Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport.
|
||||
* Callers should horizontally scroll first if `visible === false`.
|
||||
*/
|
||||
export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim();
|
||||
const lo = s => norm(s).toLowerCase();
|
||||
|
||||
const p = ${JSON.stringify(p)};
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return { error: 'no_grid_structure' };
|
||||
|
||||
// Header X-ranges (mirror of readTableScript logic, simplified). We also
|
||||
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
|
||||
// columns can share X coordinates after horizontal scroll, so cell matching
|
||||
// must respect the frozen/scrollable partition.
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const headers = [...headLine.children]
|
||||
.filter(c => c.offsetWidth > 0)
|
||||
.map(c => {
|
||||
const textEl = c.querySelector('.gridBoxText');
|
||||
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
const r = c.getBoundingClientRect();
|
||||
return { text, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
||||
})
|
||||
.filter(h => h.text);
|
||||
|
||||
const resolveCol = (name) => {
|
||||
const suffix = ' / ' + name;
|
||||
return headers.find(h => lo(h.text) === lo(name))
|
||||
|| headers.find(h => h.text.endsWith(suffix))
|
||||
|| headers.find(h => lo(h.text).includes(lo(name)));
|
||||
};
|
||||
|
||||
const targetCol = ${JSON.stringify(column)};
|
||||
const col = resolveCol(targetCol);
|
||||
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.text) };
|
||||
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (lines.length === 0) return { error: 'empty_grid' };
|
||||
|
||||
// Match cell to column by X overlap, but only among cells with the same
|
||||
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
|
||||
// cell may have the same x as a frozen one — without this guard cellAtColX
|
||||
// would silently return the frozen cell for a scrollable header.
|
||||
const cellAtColX = (line, c) => [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
||||
.find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= c.x && cx < c.right;
|
||||
});
|
||||
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
||||
|
||||
const target = ${JSON.stringify(row)};
|
||||
let line, rowIdx;
|
||||
if (typeof target === 'number') {
|
||||
if (target < 0 || target >= lines.length) {
|
||||
return { error: 'row_out_of_range', row: target, loaded: lines.length };
|
||||
}
|
||||
line = lines[target];
|
||||
rowIdx = target;
|
||||
} else if (target && typeof target === 'object') {
|
||||
const entries = Object.entries(target);
|
||||
const colsByKey = {};
|
||||
for (const [k] of entries) {
|
||||
const c = resolveCol(k);
|
||||
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.text) };
|
||||
colsByKey[k] = c;
|
||||
}
|
||||
const matches = (ln) => {
|
||||
for (const [k, v] of entries) {
|
||||
const c = colsByKey[k];
|
||||
const cell = cellAtColX(ln, c);
|
||||
const txt = cellText(cell);
|
||||
const wanted = lo(v);
|
||||
if (!txt) return false;
|
||||
const t = txt.toLowerCase();
|
||||
if (!(t === wanted || t.includes(wanted))) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
rowIdx = lines.findIndex(matches);
|
||||
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
|
||||
line = lines[rowIdx];
|
||||
} else {
|
||||
return { error: 'invalid_row_type' };
|
||||
}
|
||||
|
||||
const cell = cellAtColX(line, col);
|
||||
if (!cell) return { error: 'cell_not_in_dom', column: col.text, rowIdx };
|
||||
const r = cell.getBoundingClientRect();
|
||||
const gridBox = grid.getBoundingClientRect();
|
||||
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
|
||||
// when the rest scrolls horizontally. For non-frozen cells, "visible" means
|
||||
// inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells
|
||||
// are always visible by definition.
|
||||
const isFixed = cell.classList.contains('gridBoxFix');
|
||||
let scrollableLeft = gridBox.x;
|
||||
if (!isFixed) {
|
||||
[...line.children].forEach(b => {
|
||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||
const br = b.getBoundingClientRect();
|
||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||
}
|
||||
});
|
||||
}
|
||||
// "Visible enough to click" — the cell's CENTER is inside the scrollable area
|
||||
// and the cell's right edge is inside the grid. Strict left-edge check would
|
||||
// reject cells that 1С rendered touching the frozen-column boundary (off by 1px).
|
||||
const center = r.x + r.width / 2;
|
||||
const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width);
|
||||
return {
|
||||
x: Math.round(r.x + r.width / 2),
|
||||
y: Math.round(r.y + r.height / 2),
|
||||
cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width),
|
||||
gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width),
|
||||
scrollableLeft: Math.round(scrollableLeft),
|
||||
columnText: col.text, rowIdx, isFixed,
|
||||
cellText: cellText(cell),
|
||||
visible
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick coordinates for a focus-click on a safe cell within the grid.
|
||||
*
|
||||
* Used both for vertical reveal-loop focus and for horizontal-scroll edge focus.
|
||||
* The caller passes a profile that selects which row, which cells to exclude,
|
||||
* and (for horizontal scroll) which edge of the row to take.
|
||||
*
|
||||
* @param {string} gridSelector
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row.
|
||||
* @param {'ArrowRight'|'ArrowLeft'} [opts.direction]
|
||||
* - When set, restricts to non-frozen FULLY visible cells and picks the edge
|
||||
* cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft).
|
||||
* - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles).
|
||||
*
|
||||
* Always prefers non-checkbox cells (center-click on a checkbox would toggle it).
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (!lines.length) return null;
|
||||
|
||||
const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)};
|
||||
const direction = ${direction ? JSON.stringify(direction) : 'null'};
|
||||
|
||||
const line = (rowIdx != null && lines[rowIdx])
|
||||
|| lines.find(ln => {
|
||||
const imgBox = ln.querySelector('.gridBoxImg');
|
||||
return !imgBox?.querySelector('.gridListH, .gridListV');
|
||||
})
|
||||
|| lines[0];
|
||||
if (!line) return null;
|
||||
|
||||
let candidates;
|
||||
if (direction) {
|
||||
// Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen.
|
||||
const gridBox = grid.getBoundingClientRect();
|
||||
let scrollableLeft = gridBox.x;
|
||||
[...line.children].forEach(b => {
|
||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||
const br = b.getBoundingClientRect();
|
||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||
}
|
||||
});
|
||||
const visible = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix'))
|
||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }))
|
||||
.filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width));
|
||||
if (!visible.length) return null;
|
||||
visible.sort((a, b) => a.r.x - b.r.x);
|
||||
candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible;
|
||||
} else {
|
||||
// Generic focus mode: any visible cell past the first column (tree toggles).
|
||||
const cells = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0)
|
||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }));
|
||||
if (!cells.length) return null;
|
||||
candidates = cells.length > 1 ? cells.slice(1) : cells;
|
||||
}
|
||||
const pick = candidates.find(v => !v.checkbox) || candidates[0];
|
||||
if (!pick) return null;
|
||||
return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot grid state for reveal-loop end detection.
|
||||
* Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`.
|
||||
*
|
||||
* `firstText`/`lastText` use the first cell's `.gridBoxText` content.
|
||||
* `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight.
|
||||
*/
|
||||
export function snapshotGridScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = body.querySelectorAll('.gridLine');
|
||||
const txt = ln => ln?.querySelector('.gridBoxText')?.innerText?.trim() || '';
|
||||
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
|
||||
const vsId = 'vertScroll_' + (grid.id || '').replace(/^form\\d+_/, '');
|
||||
const vs = grid.querySelector('#' + CSS.escape(vsId));
|
||||
let hasBelow;
|
||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||
hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0;
|
||||
} else {
|
||||
hasBelow = body.scrollHeight > body.clientHeight;
|
||||
}
|
||||
return {
|
||||
firstText: txt(lines[0]),
|
||||
lastText: txt(lines[lines.length - 1]),
|
||||
lineCount: lines.length,
|
||||
selIdx,
|
||||
hasBelow
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the click target kind for `clickElement({row, column})`.
|
||||
*
|
||||
* Routing:
|
||||
* - `tableName` specified: try to match a visible grid by name (exact → contains).
|
||||
* If matched → grid. Else if form has a spreadsheet iframe → spreadsheet. Else error.
|
||||
* - `tableName` omitted: spreadsheet iframe present → spreadsheet (backward-compat).
|
||||
* Else first visible grid. Else error.
|
||||
*
|
||||
* Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`.
|
||||
*/
|
||||
export function resolveCellTargetScript(formNum, tableName) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const tableName = ${JSON.stringify(tableName || '')};
|
||||
// Spreadsheet = iframe under form prefix with non-trivial width.
|
||||
const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => {
|
||||
if (f.offsetWidth < 100) return false;
|
||||
let el = f.parentElement;
|
||||
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
||||
if (el.id && el.id.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase();
|
||||
|
||||
if (tableName) {
|
||||
const target = norm(tableName);
|
||||
const matched = grids.find(g => norm(g.id.replace(p, '')) === target)
|
||||
|| grids.find(g => norm(g.id.replace(p, '')).includes(target));
|
||||
if (matched) {
|
||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') };
|
||||
}
|
||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||
return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) };
|
||||
}
|
||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||
if (grids.length > 0) {
|
||||
const g = grids[0];
|
||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') };
|
||||
}
|
||||
return { error: 'no_spreadsheet_or_grid' };
|
||||
})()`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -20,10 +20,17 @@ export default async function({ navigateSection, openCommand, clickElement, fill
|
||||
);
|
||||
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`rows after add: ${t.rows?.length}`);
|
||||
log(`rows after add: ${t.rows?.length}, hasMore: ${JSON.stringify(t.hasMore)}`);
|
||||
assert.equal(t.rows?.length, 2, 'Должно быть 2 строки');
|
||||
assert.equal(t.rows[0]['Номенклатура'], 'Товар 01', 'Строка 0 = Товар 01');
|
||||
assert.equal(t.rows[1]['Номенклатура'], 'Товар 02', 'Строка 1 = Товар 02');
|
||||
// hasMore: две строки точно помещаются в табчасть — both false
|
||||
assert.ok(t.hasMore, 'hasMore должен быть в результате readTable');
|
||||
assert.equal(t.hasMore.below, false, 'hasMore.below должно быть false (всё видно)');
|
||||
// above либо false (видимый scrollbar), либо undefined (дин-список) — но для табчасти ждём false
|
||||
if (t.hasMore.above !== undefined) {
|
||||
assert.equal(t.hasMore.above, false, 'hasMore.above должно быть false (мы на первой странице)');
|
||||
}
|
||||
});
|
||||
|
||||
await step('edit: изменить количество в строке 0 через fillTableRow row:0', async () => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
export const name = 'clickElement({row, column}): cell click on grids + spreadsheet backward-compat';
|
||||
export const tags = ['cell-click', 'smoke'];
|
||||
export const timeout = 120000;
|
||||
|
||||
export default async function({
|
||||
navigateSection, navigateLink, openCommand, clickElement, fillFields, fillTableRow,
|
||||
readTable, readSpreadsheet, closeForm, getFormState, wait, assert, step, log
|
||||
}) {
|
||||
|
||||
// ── Spreadsheet backward-compat ─────────────────────────────────────────────
|
||||
await step('spreadsheet: cell click by (row, column) still works (regression guard)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Остатки товаров');
|
||||
await clickElement('Еще');
|
||||
await clickElement('Установить стандартные настройки');
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const r = await readSpreadsheet();
|
||||
assert.ok(r.data?.length > 0, 'В отчёте есть данные');
|
||||
const firstHeader = r.headers[0];
|
||||
const before = await getFormState();
|
||||
const res = await clickElement({ row: 0, column: firstHeader });
|
||||
log(`spreadsheet click: ${JSON.stringify(res.clicked)}`);
|
||||
assert.equal(res.clicked?.kind, 'spreadsheetCell', 'kind=spreadsheetCell — без table роутер ушёл в spreadsheet');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
// ── Grid cell click: catalog list with dblclick to open item ────────────────
|
||||
await step('catalog list: dblclick by {row: filter, column} opens the item', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
const t = await readTable();
|
||||
assert.ok(t.rows?.length > 0, 'Список Контрагентов не пуст');
|
||||
// Используем фикстуру стенда: ООО Север в колонке Наименование
|
||||
const before = await getFormState();
|
||||
const res = await clickElement(
|
||||
{ row: { 'Наименование': 'ООО Север' }, column: 'Наименование' },
|
||||
{ dblclick: true }
|
||||
);
|
||||
log(`clicked: ${JSON.stringify(res.clicked)}`);
|
||||
assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell');
|
||||
assert.equal(res.clicked?.dblclick, true, 'dblclick=true прокинут');
|
||||
await wait(1);
|
||||
const after = await getFormState();
|
||||
// На синтетическом стенде поведение dblclick по ячейке может не открывать форму,
|
||||
// если колонка не "главная" — главное, что клик завершился без ошибки и тип события правильный.
|
||||
if (after.formCount > before.formCount) {
|
||||
log('форма открылась — закрываем');
|
||||
await closeForm();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Grid cell click on tabular section + row by numeric index ──────────────
|
||||
await step('tabular section: click cell by row:0 + column (table specified)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Контрагент': 'ООО Север' });
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
|
||||
{ table: 'Товары', add: true }
|
||||
);
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 02', 'Количество': '3', 'Цена': '200' },
|
||||
{ table: 'Товары', add: true }
|
||||
);
|
||||
const res = await clickElement(
|
||||
{ row: 0, column: 'Количество' },
|
||||
{ table: 'Товары' }
|
||||
);
|
||||
log(`clicked: ${JSON.stringify(res.clicked)}`);
|
||||
assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell');
|
||||
assert.equal(res.clicked?.row, 0, 'row=0 сохранён в результате');
|
||||
assert.equal(res.clicked?.column, 'Количество', 'column=Количество');
|
||||
});
|
||||
|
||||
// ── readTable.hasMore on tabular section ───────────────────────────────────
|
||||
await step('readTable.hasMore: 2-row table shows hasMore.below=false', async () => {
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`hasMore: ${JSON.stringify(t.hasMore)}`);
|
||||
assert.ok(t.hasMore, 'hasMore присутствует в результате');
|
||||
assert.equal(t.hasMore.below, false, 'hasMore.below=false для двух строк (всё видно)');
|
||||
});
|
||||
|
||||
// ── Error path: row not in DOM, no scroll → understandable error ───────────
|
||||
await step('row_not_found без scroll бросает ошибку с подсказкой', async () => {
|
||||
let caught = null;
|
||||
try {
|
||||
await clickElement(
|
||||
{ row: { 'Количество': 'НЕСУЩЕСТВУЮЩЕЕ_ЗНАЧЕНИЕ_123' }, column: 'Количество' },
|
||||
{ table: 'Товары' } // без scroll
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
assert.ok(caught, 'Должна быть ошибка');
|
||||
log(`error: ${caught.message}`);
|
||||
assert.ok(/not found/i.test(caught.message), 'Сообщение упоминает not found');
|
||||
assert.ok(/scroll/i.test(caught.message), 'Сообщение содержит подсказку про scroll: true');
|
||||
});
|
||||
|
||||
// ── Error path: out of range numeric row ───────────────────────────────────
|
||||
await step('row_out_of_range на числовом индексе бросает понятную ошибку', async () => {
|
||||
let caught = null;
|
||||
try {
|
||||
await clickElement(
|
||||
{ row: 9999, column: 'Количество' },
|
||||
{ table: 'Товары' }
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
assert.ok(caught, 'Должна быть ошибка');
|
||||
log(`error: ${caught.message}`);
|
||||
assert.ok(/out of range/i.test(caught.message), 'Сообщение упоминает out of range');
|
||||
assert.ok(/virtualized/i.test(caught.message) || /DOM window/i.test(caught.message),
|
||||
'Сообщение объясняет про виртуализацию / DOM window');
|
||||
});
|
||||
|
||||
// ── Cleanup ────────────────────────────────────────────────────────────────
|
||||
await step('cleanup: close document', async () => {
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
// Note: reveal-loop (scroll:true) algorithm verified manually on bp-demo
|
||||
// (catalog Контрагенты, group Покупатели, ~22 items requiring page-down).
|
||||
// The synthetic stand has issues with rapid sequential doc opens that prevent
|
||||
// a stable >30-row table setup here — left for a future enhancement of _hooks.
|
||||
}
|
||||
Reference in New Issue
Block a user