mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-23 13:11:05 +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:
co-authored by
Claude Opus 4.7
parent
dff3ced847
commit
e05c0a4a61
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user