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
@@ -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;