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
+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();
});