fix(web-test): единая модель колонок грида — colindex первым, геометрия запасной

Резолверов колонок было пять, все независимые, и каждый ломался по-своему:
readTable (геометрия X + Y-подряды), clickElement (геометрия X, без Y),
поиск строки {кол: знач} (геометрия X, без Y и fixed-гарда), filterList
(порядковый индекс шапки), fillTableRow (colindex — единственный целый).

Механика поломки (снята живьём на списке задач ERP): шапка «Исполнитель»
широкая (x 1085..1515) и накрывает «Срок» (1085..1251) и «Выполнена»
(1251..1515). Ячейка «Исполнитель» имеет центр 1300 → приписывается к группе
«Выполнена» → та получает лишний под-ряд → срабатывает эвристика «объединённая
шапка» → фантомные «Выполнена 1/2», а значения соседей склеиваются через ' / '.

Теперь COLUMN_MODEL_FN (dom/_shared.mjs) — единственный источник правды:
buildColumnModel / columnForCell / cellForColumn / resolveColumnByName. Идентичность
колонки — colindex (собственный id колонки в 1С, есть и на шапке, и на ячейке);
геометрия работает только там, где своей шапки у ячейки нет — под-ряды
объединённой шапки («Субконто Дт» над тремя ячейками). Путь записи пришёл к этому
решению раньше (grid-edit.mjs: «reliable across merged headers») — остальные
выровнены по нему.

Следствие: имя колонки из readTable теперь годится для клика/заполнения/фильтра —
раньше readTable отдавал «Субконто Дт 2», а клик про такое имя не знал.

Попутно закрыт второй дефект: безымянная picture-колонка определялась по ПЕРВОЙ
строке, а picField над Boolean не рисует картинку при Ложь → колонка пропадала из
columns целиком. Модель сэмплит до 10 строк и ищет ячейку по colindex.

Проверено:
- 24-multirow-header (стенд, оба паттерна ERP) — зелёный; до правки красный;
- клик проверяется по факту (DOM select+focus), а не по эху clicked.column:
  до правки клик по «Срок» молча жал «Исполнитель 2» и рапортовал успех;
- живьём на ERP: список задач — 9 честных колонок вместо фантомов, значения на
  местах; форма операции (шапка 2 этажа, строка 3 под-ряда) — «Субконто Дт/Кт 1..3»
  сохранены, ничего не поехало;
- полный регресс 28/28.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-17 11:41:44 +03:00
parent 96b38d9f91
commit 7696a8b3ee
9 changed files with 337 additions and 288 deletions
+2
View File
@@ -170,6 +170,8 @@ const form = await getFormState();
#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset, hasMore }`
Read actual grid data with pagination. Each row is `{ columnName: value }`.
**Names from `columns` work everywhere**`clickElement({ row, column })`, `fillTableRow`, `filterList` and `{ column: value }` row filters all resolve a column the same way `readTable` names it. Multi-row headers included: a header stacked over several sub-rows is reported (and targeted) as `'Имя 1'`, `'Имя 2'`, … — e.g. the accounting entries grid gives `'Субконто Дт 1'..'Субконто Дт 3'`.
| Option | Default | Description |
|--------|---------|-------------|
| `maxRows` | 20 | Max rows to return per call |
+1 -1
View File
@@ -1,4 +1,4 @@
// web-test dom v1.19 — facade re-exporting injectable DOM scripts from dom/
// web-test dom v1.20 — 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.
+204 -1
View File
@@ -1,4 +1,4 @@
// web-test dom shared v1.3 — embedded JS function constants
// web-test dom shared v1.4 — embedded JS function constants
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Shared function strings embedded into page.evaluate() generators.
@@ -105,6 +105,209 @@ export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) {
return cols;
}`;
/**
* Single source of truth for columns of a grid WITH a header — the headed twin of
* synthHeaderlessColumns above, and for the same reason: a column name must map to the same
* physical cell for readers (readTable) and resolvers (click, row search, filter, fill).
*
* Column identity is `colindex` — 1С's own column id, present on both header boxes and body
* cells. Geometry is the FALLBACK, used only for cells that have no header of their own
* (sub-rows of a merged header, e.g. «Субконто Дт» over three stacked cells).
*
* Why colindex first: a wide header (ERP task list, «Исполнитель» spanning x 1085…1515) covers
* the narrow headers below it («Срок» 1085…1251, «Выполнена» 1251…1515). Matching a cell by its
* center-x alone puts the «Исполнитель» cell (center 1300) into the «Выполнена» group — which
* both fakes a merged header (phantom «Выполнена 1/2») and glues foreign values together.
* The write path (grid-edit.mjs) already resolves cells by colindex for exactly this reason.
*
* Column: { name, text, title, ci, x, right, y, h, fixed, kind?, subIdx? }
* - ci — anchor; null for expanded sub-columns (their cells carry a different colindex).
* - subIdx — set on «Имя 1/2/3» columns expanded from ONE header over several sub-rows;
* such a cell is found by its Y order inside the header's x-range.
*/
export const COLUMN_MODEL_FN = HEADERLESS_GRID_FN + `
function picInfoShared(cell) {
if (!cell) return null;
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
const dib = cell.querySelector('.gridBoxImg .dIB');
if (!dib) return null;
const bg = dib.style.backgroundImage || '';
if (!bg.includes('pictureCollection/picture/')) return null;
const m = bg.match(/[?&]gx=(\\d+)/);
return { gx: m ? m[1] : '0' };
}
function buildColumnModel(grid) {
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
const empty = { columns: [], byCi: {}, groups: new Map(), subRows: {}, multiRow: {}, headless: !head };
if (!body) return empty;
if (!head) {
const cols = synthHeaderlessColumns(grid).map(c => ({
name: c.name, text: c.name, title: '', ci: c.colindex, subTarget: c.subTarget,
kind: c.kind, x: 0, right: 0, y: 0, h: 0, fixed: false,
}));
const byCi = {};
cols.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
return { columns: cols, byCi, groups: new Map(), subRows: {}, multiRow: {}, headless: true };
}
const headLine = head.querySelector('.gridLine') || head;
const lines = [...body.querySelectorAll('.gridLine')];
const cellByCi = (line, ci) => [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === ci);
const columns = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const ci = box.getAttribute('colindex');
const textEl = box.querySelector('.gridBoxText');
const text = ((textEl || box).innerText || '').trim().replace(/\\n/g, ' ');
const title = (box.getAttribute('title') || '').trim();
const r = box.getBoundingClientRect();
const base = { ci, x: r.x, right: r.x + r.width, y: r.y, h: r.height,
fixed: box.classList.contains('gridBoxFix') };
if (text) { columns.push(Object.assign(base, { name: text, text: text, title: title })); return; }
// Unnamed header — a column only if its cells hold a checkbox or a picture. 1С doesn't
// expose the technical name, so it is named by the header tooltip.
// Sample SEVERAL rows: a picture bound to a Boolean draws nothing for false, so an empty
// first row is not evidence that the column has no pictures at all.
let kind = null;
for (const line of lines.slice(0, 10)) {
const cell = ci != null ? cellByCi(line, ci) : null;
if (!cell) continue;
if (cell.querySelector('.checkbox')) { kind = 'checkbox'; break; }
if (picInfoShared(cell)) { kind = 'picture'; break; }
}
if (!kind && picInfoShared(box)) kind = 'picture';
if (!kind) return;
let name = kind === 'checkbox' ? '(checkbox)' : (title || '(picture)');
if (columns.some(c => c.name === name)) {
let n = 2;
while (columns.some(c => c.name === name + ' ' + n)) n++;
name = name + ' ' + n;
}
columns.push(Object.assign(base, { name: name, text: '', title: title, kind: kind }));
});
const keyOf = c => Math.round(c.x) + ':' + Math.round(c.right);
const groups = new Map();
columns.forEach(c => { const k = keyOf(c); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(c); });
for (const hdrs of groups.values()) hdrs.sort((a, b) => a.y - b.y);
const byCi = {};
columns.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
// Sub-rows per x-group, measured on the first data line. A cell belongs to the group of its
// OWN header whenever colindex says so; only header-less cells are placed geometrically.
const subRows = {};
if (lines[0]) {
[...lines[0].children].forEach(box => {
if (box.offsetWidth === 0) return;
const ci = box.getAttribute('colindex');
const own = ci != null ? byCi[ci] : null;
let key = null;
const r = box.getBoundingClientRect();
if (own) key = keyOf(own);
else {
const cx = r.x + r.width / 2;
for (const [k, hdrs] of groups) {
if (cx >= hdrs[0].x && cx < hdrs[0].right) { key = k; break; }
}
}
if (key == null) return;
(subRows[key] = subRows[key] || []).push({ y: r.y });
});
Object.keys(subRows).forEach(k => subRows[k].sort((a, b) => a.y - b.y));
}
// Stacked headers (2+ over several sub-rows) → match by Y order.
// ONE header over several sub-rows → merged header: expand into «Имя 1..N».
const multiRow = {};
for (const [k, hdrs] of groups) {
const subs = subRows[k];
if (!subs || subs.length <= 1) continue;
if (hdrs.length >= 2) { multiRow[k] = hdrs; continue; }
const base = hdrs[0];
const at = columns.indexOf(base);
columns.splice(at, 1);
if (base.ci != null && byCi[base.ci] === base) delete byCi[base.ci];
const expanded = [];
for (let si = 0; si < subs.length; si++) {
const col = Object.assign({}, base, {
name: base.name + ' ' + (si + 1), ci: null,
y: base.y + si, h: base.h / subs.length, subIdx: si,
});
columns.splice(at + si, 0, col);
expanded.push(col);
}
groups.set(k, expanded);
multiRow[k] = expanded;
}
return { columns: columns, byCi: byCi, groups: groups, subRows: subRows, multiRow: multiRow, headless: false };
}
/** Cell → column. colindex first; geometry only for cells without a header of their own. */
function columnForCell(model, box) {
const ci = box.getAttribute('colindex');
if (ci != null && model.byCi[ci]) return model.byCi[ci];
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
const fixed = box.classList.contains('gridBoxFix');
for (const k of Object.keys(model.multiRow)) {
const hdrs = model.multiRow[k];
if (cx < hdrs[0].x || cx >= hdrs[0].right) continue;
const subs = model.subRows[k];
if (subs) {
const si = subs.findIndex(s => Math.abs(s.y - r.y) < 5);
if (si >= 0 && si < hdrs.length) return hdrs[si];
}
let best = hdrs[0], bd = Infinity;
for (const h of hdrs) { const d = Math.abs(r.y - h.y); if (d < bd) { bd = d; best = h; } }
return best;
}
return model.columns.find(c => cx >= c.x && cx < c.right && c.fixed === fixed) || null;
}
/** Column → cell inside a given line. Mirror of columnForCell, same precedence. */
function cellForColumn(model, line, col) {
const boxes = [...line.children].filter(b => b.offsetWidth > 0);
if (col.subIdx != null) {
const inGroup = boxes
.filter(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2;
return cx >= col.x && cx < col.right && b.classList.contains('gridBoxFix') === col.fixed;
})
.sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y);
return inGroup[col.subIdx] || null;
}
if (col.ci != null) {
const hit = boxes.find(b => b.getAttribute('colindex') === col.ci);
if (hit) return hit;
}
return boxes
.filter(b => b.classList.contains('gridBoxFix') === col.fixed)
.find(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2;
return cx >= col.x && cx < col.right;
}) || null;
}
/** Column by user-supplied name: exact → «Группа / Имя» suffix → substring. */
function resolveColumnByName(model, name) {
const lo = s => (s || '').toLowerCase().replace(/ё/g, 'е').trim();
const cand = c => [c.name, c.text, c.title].filter(Boolean);
const n = lo(name);
const suffix = lo(' / ' + name);
return model.columns.find(c => cand(c).some(t => lo(t) === n))
|| model.columns.find(c => cand(c).some(t => lo(t).endsWith(suffix)))
|| model.columns.find(c => cand(c).some(t => lo(t).includes(n)))
|| null;
}`;
/** Detect active form number. Picks form with most visible elements, skipping form0.
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
+16 -21
View File
@@ -1,5 +1,6 @@
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
// web-test dom/filter v1.1 — DOM scripts for filterList / unfilterList
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { COLUMN_MODEL_FN } from './_shared.mjs';
/**
* Find the first grid cell on the form and return its center coords.
@@ -39,32 +40,26 @@ export function findColumnFirstCellCoordsScript(formNum, field) {
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0);
if (!grid) return { error: 'no_grid' };
${COLUMN_MODEL_FN}
const targetField = ${JSON.stringify(field)};
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
let colIndex = -1;
let startsWithIdx = -1;
let includesIdx = -1;
for (let i = 0; i < headers.length; i++) {
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
if (!t) continue;
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
if (tl === fl) { colIndex = i; break; }
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
}
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
if (!rows.length) return { error: 'no_rows' };
if (colIndex < 0) {
// Column resolution — shared model (dom/_shared.mjs), same as readTable/click. The old
// positional index (Nth header box → Nth cell box) breaks on multi-row headers, where
// header and cell counts and order differ.
const model = buildColumnModel(grid);
const col = resolveColumnByName(model, targetField);
if (!col) {
// Unknown column → click the first cell and let the caller fall back to DLB.
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (!cells.length) return { error: 'no_cells' };
const r = cells[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
const r0 = cells[0].getBoundingClientRect();
return { x: Math.round(r0.x + r0.width / 2), y: Math.round(r0.y + r0.height / 2), needDlb: true };
}
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (colIndex >= cells.length) return { error: 'cell_not_found' };
const r = cells[colIndex].getBoundingClientRect();
const cell = cellForColumn(model, rows[0], col);
if (!cell) return { error: 'cell_not_found' };
const r = cell.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
@@ -1,7 +1,7 @@
// web-test dom/grid-edit v1.2 — DOM scripts for row-fill (grid edit-time operations)
// web-test dom/grid-edit v1.3 — DOM scripts for row-fill (grid edit-time operations)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
import { HEADERLESS_GRID_FN } from './_shared.mjs';
import { HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs';
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
@@ -67,45 +67,28 @@ export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
*/
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
return `(() => {
${HEADERLESS_GRID_FN}
${COLUMN_MODEL_FN}
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!body) return { error: 'no_grid_body' };
// Read columns to find target colindex (+ subTarget for headerless split mark-boxes)
const cols = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
});
} else {
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
}
// Column resolution — shared model (dom/_shared.mjs): same names and same physical cell
// as readTable/click report, including «Имя 1/2/3» of an expanded merged header.
const model = buildColumnModel(grid);
const keys = ${JSON.stringify(fieldKeys)};
let targetColindex = null, targetSub = null;
let targetCol = null;
for (const key of keys) {
const exact = cols.find(c => c.text === key);
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; break; }
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; break; }
targetCol = resolveColumnByName(model, key);
if (targetCol) break;
}
const targetSub = targetCol ? (targetCol.subTarget || null) : null;
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Find body cell by colindex (reliable across merged headers)
let box = null;
if (targetColindex != null) {
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
}
let box = targetCol ? cellForColumn(model, line, targetCol) : null;
// Fallback: second visible box (skip checkbox/N column)
if (!box) {
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
@@ -134,39 +117,30 @@ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
*/
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
return `(() => {
${HEADERLESS_GRID_FN}
${COLUMN_MODEL_FN}
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!body) return null;
const cols = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
});
} else {
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
}
const model = buildColumnModel(grid);
const kl = ${JSON.stringify(key.toLowerCase())};
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
let targetColindex = null, targetSub = null;
const exact = cols.find(c => c.text === kl);
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; }
else {
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; }
// Extra "no-space/no-dash" fuzz on top of the shared resolver: header «Группа Контрагентов»
// must match the key «ГруппаКонтрагентов».
let targetCol = resolveColumnByName(model, kl);
if (!targetCol) {
const squash = s => (s || '').toLowerCase().replace(/ё/g, 'е').replace(/[\\s\\-]+/g, '');
const klNoSpace = squash(kl);
targetCol = model.columns.find(c => {
const t = squash(c.name);
return t && (t.includes(klNoSpace) || klNoSpace.includes(t));
}) || null;
}
if (targetColindex == null) return null;
if (!targetCol) return null;
const targetSub = targetCol.subTarget || null;
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return null;
const line = rows[${row}];
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
const box = cellForColumn(model, line, targetCol);
if (!box) return null;
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
let cell;
+25 -204
View File
@@ -1,6 +1,6 @@
// web-test dom/grid v1.13 — grid resolution + table reading + edit-time helpers
// web-test dom/grid v1.14 — grid resolution + table reading + edit-time helpers
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.mjs';
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs';
import { ROW_STATE_FN } from './row-state.mjs';
/**
@@ -95,7 +95,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
const m = bg.match(/[?&]gx=(\\d+)/);
return { gx: m ? m[1] : '0' };
}
${HEADERLESS_GRID_FN}
${COLUMN_MODEL_FN}
${ROW_STATE_FN}
// Write the leading row-state sprite onto a row: _rowPic (raw, always) + decoded booleans
@@ -192,116 +192,10 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
return result;
}
// Extract column headers with X-coordinates for alignment
const columns = [];
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!text) {
// Unnamed column — check if data cells contain checkboxes or pictures.
// Picture columns have no header text (only an icon + a title tooltip); 1С
// doesn't expose the technical column name in the DOM, so we name them by
// the header's title attribute, falling back to '(picture)'.
const firstLine = body?.querySelector('.gridLine');
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
const r = box.getBoundingClientRect();
if (cells[idx]?.querySelector('.checkbox')) {
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
} else if (picInfo(box) || picInfo(cells[idx])) {
let title = (box.getAttribute('title') || '').trim() || '(picture)';
// Disambiguate duplicate picture-column names with a numeric suffix.
if (columns.some(c => c.text === title)) {
let n = 2;
while (columns.some(c => c.text === title + ' ' + n)) n++;
title = title + ' ' + n;
}
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
}
return;
}
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
});
// Multi-row grid support: detect stacked/merged headers.
// Group headers by X-range. For each group, count data sub-rows from first line.
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
const xGroups = new Map();
columns.forEach(c => {
const key = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGroups.has(key)) xGroups.set(key, []);
xGroups.get(key).push(c);
});
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
const firstDataLine = body?.querySelector('.gridLine');
const subRowMap = new Map();
if (firstDataLine) {
[...firstDataLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
for (const [key, hdrs] of xGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
if (!subRowMap.has(key)) subRowMap.set(key, []);
subRowMap.get(key).push({ y: r.y });
break;
}
}
});
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
}
const multiRowGroups = new Map();
for (const [key, hdrs] of xGroups) {
const subs = subRowMap.get(key);
if (!subs || subs.length <= 1) continue;
if (hdrs.length >= 2) {
multiRowGroups.set(key, hdrs);
} else if (hdrs.length === 1 && subs.length > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
const expanded = [];
for (let si = 0; si < subs.length; si++) {
const numbered = {
text: base.text + ' ' + (si + 1),
x: base.x, w: base.w, right: base.right,
y: base.y + si, h: base.h / subs.length, _subIdx: si
};
columns.splice(baseIdx + si, 0, numbered);
expanded.push(numbered);
}
multiRowGroups.set(key, expanded);
}
}
function matchColumn(cellX, cellW, cellY) {
const cx = cellX + cellW / 2;
for (const [key, hdrs] of multiRowGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
const subs = subRowMap.get(key);
if (subs) {
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
}
let best = hdrs[0], bestDist = Infinity;
for (const h of hdrs) {
const dist = Math.abs(cellY - h.y);
if (dist < bestDist) { bestDist = dist; best = h; }
}
return best;
}
}
return columns.find(c => cx >= c.x && cx < c.right);
}
// Columns + cell↔column mapping — single source of truth in dom/_shared.mjs.
// colindex first, geometry only for cells without a header of their own.
const model = buildColumnModel(grid);
const columns = model.columns;
// Extract data rows from gridBody
const allLines = body.querySelectorAll('.gridLine');
@@ -312,7 +206,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
const line = allLines[i];
if (!line) break;
const row = {};
columns.forEach(c => { row[c.text] = ''; });
columns.forEach(c => { row[c.name] = ''; });
[...line.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
@@ -330,11 +224,9 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
else return;
}
}
// Match cell to column by X+Y overlap (multi-row aware)
const r = box.getBoundingClientRect();
const col = matchColumn(r.x, r.width, r.y);
const col = columnForCell(model, box);
if (col) {
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
row[col.name] = row[col.name] ? row[col.name] + ' / ' + val : val;
}
});
// Detect row kind: group (gridListH), parent/up (gridListV), or element
@@ -388,7 +280,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
hasMore = { below: body.scrollHeight > body.clientHeight };
}
}
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
const result = { name, columns: columns.map(c => c.name), rows, total, offset: ${offset}, shown: rows.length, hasMore };
if (isTree) result.viewMode = 'tree';
if (hasGroups) result.hierarchical = true;
return result;
@@ -523,7 +415,7 @@ export function getSelectedOrLastRowIndexScript(gridSelector) {
export function scanGridRowsScript(formNum, search) {
return `(() => {
${ROW_CLICK_POINT_FN}
${HEADERLESS_GRID_FN}
${COLUMN_MODEL_FN}
const p = 'form${formNum}_';
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
if (!grid) return null;
@@ -547,41 +439,14 @@ export function scanGridRowsScript(formNum, search) {
if (!search || (isObj && !Object.keys(search).length)) {
sel = lines[0]; matchKind = 'first';
} else if (isObj) {
// Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol.
const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead');
let headers;
if (headLine) {
headers = [...headLine.children]
.filter(c => c.offsetWidth > 0)
.map(c => {
const t = (c.querySelector('.gridBoxText') || c).innerText || '';
const title = c.getAttribute('title') || '';
const r = c.getBoundingClientRect();
return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width };
})
.filter(h => h.name);
} else {
// Headerless: synthesized columns anchored by colindex.
headers = synthHeaderlessColumns(grid).map(c => ({ name: c.name, text: c.name, title: '', x: 0, right: 0, colindex: c.colindex }));
}
const resolveCol = name => {
const n = norm(name);
const cand = h => [h.text, h.title].filter(Boolean);
return headers.find(h => cand(h).some(t => norm(t) === n))
|| headers.find(h => cand(h).some(t => norm(t).includes(n)));
};
const cellAtCol = (line, col) => {
if (col.colindex != null) return visCells(line).find(b => b.getAttribute('colindex') === col.colindex);
return visCells(line).find(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2;
return cx >= col.x && cx < col.right;
});
};
// Column resolution — shared model (dom/_shared.mjs): colindex first, geometry only
// for cells without a header of their own. Same names as readTable reports.
const model = buildColumnModel(grid);
const cellAtCol = (line, col) => cellForColumn(model, line, col);
const keys = Object.keys(search);
const cols = {};
for (const k of keys) {
const c = resolveCol(k);
const c = resolveColumnByName(model, k);
if (!c) return { rowCount: lines.length, error: 'filter_column_not_found', column: k, visibleSample };
cols[k] = c;
}
@@ -664,66 +529,22 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) {
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!body) return { error: 'no_grid_structure' };
${HEADERLESS_GRID_FN}
${COLUMN_MODEL_FN}
const isHeadless = !head;
// 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.
let headers;
if (head) {
const headLine = head.querySelector('.gridLine') || head;
headers = [...headLine.children]
.filter(c => c.offsetWidth > 0)
.map(c => {
const textEl = c.querySelector('.gridBoxText');
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
// Picture/icon columns have no header text — fall back to the title tooltip
// (mirrors readTable naming) so they can still be targeted for clicking.
const title = (c.getAttribute('title') || '').trim();
const r = c.getBoundingClientRect();
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
})
.filter(h => h.name);
} else {
// Headerless: synthesized columns anchored by colindex (cellAtColX matches by colindex).
headers = synthHeaderlessColumns(grid).map(c => ({ text: c.name, title: '', name: c.name, x: 0, right: 0, fixed: false, colindex: c.colindex, subTarget: c.subTarget }));
}
const resolveCol = (name) => {
const suffix = ' / ' + name;
const cand = h => [h.text, h.title].filter(Boolean);
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
};
// Columns + name→column + column→cell — single source of truth in dom/_shared.mjs,
// shared with readTable, so a name it reports (incl. expanded «Имя 1/2/3») is clickable.
const model = buildColumnModel(grid);
const headers = model.columns;
const targetCol = ${JSON.stringify(column)};
const col = resolveCol(targetCol);
const col = resolveColumnByName(model, targetCol);
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
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) => {
// Headerless columns carry colindex → match the body cell directly (robust,
// and returns the same box for both logical columns of a combined mark-box).
if (c.colindex != null) {
return [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === c.colindex);
}
return [...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 cellAtColX = (line, c) => cellForColumn(model, line, c);
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
const target = ${JSON.stringify(row)};
@@ -738,7 +559,7 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) {
const entries = Object.entries(target);
const colsByKey = {};
for (const [k] of entries) {
const c = resolveCol(k);
const c = resolveColumnByName(model, k);
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
colsByKey[k] = c;
}
+5 -1
View File
@@ -252,7 +252,11 @@ console.log('Расшифровка:', JSON.stringify(drilldown.rows));
#### readTable — подробнее
Каждая строка — объект `{ columnName: value }`. Специальные поля для иерархии и дерева:
Каждая строка — объект `{ columnName: value }`.
**Имена из `columns` годятся везде**`clickElement({ row, column })`, `fillTableRow`, `filterList` и фильтры строки `{ колонка: значение }` разрешают колонку так же, как её назвал `readTable`. В том числе на многострочных шапках: шапка над несколькими под-рядами выводится (и адресуется) как `'Имя 1'`, `'Имя 2'`, … — например, грид проводок даёт `'Субконто Дт 1'..'Субконто Дт 3'`.
Специальные поля для иерархии и дерева:
- `_kind: 'group'` — группа в иерархическом списке
- `_tree: 'expanded'|'collapsed'` — состояние узла дерева
+9
View File
@@ -38,6 +38,15 @@ export default async function({ navigateSection, openCommand, clickElement, clos
// и потеряет состояние. Гард ниже — через DOM: колонка безымянная и в columns не
// попадает (её наличие readTable определяет по первой строке, а там Posted=false →
// картинки нет), поэтому проверить её через публичный API нельзя.
// Колонка безымянная (titleLocation:none) → readTable называет её '(picture)'. Её наличие
// определяется сэмплированием НЕСКОЛЬКИХ строк: picField над Posted не рисует картинку при
// Ложь, а первая строка списка — непроведённый документ. По одной первой строке колонка
// выпадала из columns целиком.
const tt = await readTable({ maxRows: 50 });
assert.includes(tt.columns, '(picture)', 'безымянная picture-колонка есть в columns');
const postedRow = tt.rows.find(r => r['Комментарий'] === 'StatePosted');
assert.equal(postedRow['(picture)'], 'pic:0', 'у проведённого документа картинка нарисована');
const page = await getPage();
const icons = await page.evaluate(() => {
const grid = [...document.querySelectorAll('.grid')].find(g => g.offsetWidth > 0 && g.offsetHeight > 0);
+49 -8
View File
@@ -13,7 +13,20 @@ export const timeout = 120000;
// шапок у них НЕТ. Здесь разворот в «Субконто 1/2/3» — правильное поведение, его
// обязана сохранить любая правка.
export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillTableRow, assert, step, log }) {
export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillTableRow, getPage, assert, step, log }) {
// Куда клик попал НА САМОМ ДЕЛЕ. Результат clickElement возвращает запрошенное имя колонки
// (эхо), а не разрешённую ячейку, — по нему промах неотличим от попадания. Нажатая ячейка
// помечается в DOM классами select+focus.
const focusedCell = async () => {
const page = await getPage();
return page.evaluate(() => {
const grid = [...document.querySelectorAll('.grid')].find(g => g.offsetWidth > 0 && g.offsetHeight > 0);
const box = [...grid.querySelectorAll('.gridBody [colindex]')]
.find(b => b.classList.contains('select') && b.classList.contains('focus'));
return box ? { ci: box.getAttribute('colindex'), text: (box.innerText || '').trim() } : null;
});
};
await step('setup: открыть обработку', async () => {
await navigateSection('Склад');
@@ -53,24 +66,39 @@ export default async function({ navigateSection, openCommand, clickElement, clos
assert.equal(r['Субконто 3'], 'Субконто3 1', 'Субконто 3');
});
await step('click: clickElement({row, column}) попадает в нужную колонку', async () => {
await step('click: clickElement({row, column}) попадает в нужную ячейку', async () => {
const res = await clickElement({ row: { 'Код': 'К2' }, column: 'Срок' });
log(`clicked: ${JSON.stringify(res.clicked)}`);
assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell');
assert.equal(res.clicked?.column, 'Срок', 'column=Срок');
// Проверяем факт, а не эхо: до правки клик молча уходил в «Исполнитель 2» и рапортовал успех.
const cell = await focusedCell();
log(`focused: ${JSON.stringify(cell)}`);
assert.ok(cell, 'нажатая ячейка помечена в DOM');
assert.equal(cell.text, 'Срок 2', `клик попал в ячейку «Срок» строки К2, а не в соседнюю (got «${cell.text}»)`);
});
await step('click: имя колонки из readTable пригодно для клика (единство именования)', async () => {
const t = await readTable();
assert.includes(t.columns, 'Субконто 2', 'имя развёрнутой колонки из readTable');
const res = await clickElement({ row: { 'Код': 'К2' }, column: 'Субконто 2' });
log(`clicked: ${JSON.stringify(res.clicked)}`);
assert.equal(res.clicked?.kind, 'gridCell', 'по имени из readTable клик находит ячейку');
const cell = await focusedCell();
log(`focused: ${JSON.stringify(cell)}`);
assert.equal(cell?.text, 'Субконто2 2', `клик попал во второй под-ряд «Субконто» (got «${cell?.text}»)`);
});
await step('fill: fillTableRow пишет значение в нужную колонку', async () => {
// Путь записи ищет ячейку по colindex (grid-edit.mjs), поэтому ожидается зелёным
// и до правки резолверов чтения/клика — это замер, а не регресс.
await step('row-filter: строка ищется по «украденной» колонке', async () => {
// Фильтр строки идёт через свой резолвер (findRowInGridScript) — тоже геометрия по x,
// причём без Y-логики. Ищем строку по «Срок», чью ячейку ворует широкий «Исполнитель».
const res = await clickElement({ row: { 'Срок': 'Срок 2' }, column: 'Код' });
assert.equal(res.clicked?.kind, 'gridCell', 'строка найдена по значению узкой колонки');
const cell = await focusedCell();
log(`focused: ${JSON.stringify(cell)}`);
assert.equal(cell?.text, 'К2', `найдена именно строка К2 (got «${cell?.text}»)`);
});
await step('fill: fillTableRow пишет в колонку со своей шапкой', async () => {
// Путь записи ищет ячейку по colindex (grid-edit.mjs) — ожидается зелёным и до правки
// резолверов чтения/клика. Это замер, а не регресс.
await fillTableRow({ 'Срок': 'Срок изменён' }, { row: { 'Код': 'К3' } });
const t = await readTable();
const row = t.rows.find(r => r['Код'] === 'К3');
@@ -80,6 +108,19 @@ export default async function({ navigateSection, openCommand, clickElement, clos
assert.equal(row['Выполнена'], 'Выполнена 3', 'соседняя узкая колонка не задета');
});
await step('fill: fillTableRow пишет в РАЗВЁРНУТУЮ колонку (шапки у ячейки нет)', async () => {
// «Субконто 2» — под-ряд объединённой шапки: своей шапки у ячейки нет, у группы ci=6,
// а ячейки идут ci=7/8/9. Проверяем, что запись попадает во второй под-ряд, а не в
// первый и не в соседа.
await fillTableRow({ 'Субконто 2': 'СК2 изменён' }, { row: { 'Код': 'К1' } });
const t = await readTable();
const row = t.rows.find(r => r['Код'] === 'К1');
log(`after fill: ${JSON.stringify(row)}`);
assert.equal(row['Субконто 2'], 'СК2 изменён', 'значение попало в «Субконто 2»');
assert.equal(row['Субконто 1'], 'Субконто1 1', '«Субконто 1» не задета');
assert.equal(row['Субконто 3'], 'Субконто3 1', '«Субконто 3» не задета');
});
await step('cleanup: закрыть форму', async () => {
await closeForm();
});