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 16:14:27 +03:00
co-authored by Claude Opus 4.8
parent 96b38d9f91
commit 7696a8b3ee
9 changed files with 337 additions and 288 deletions
+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 + `