From 006e64405cdf2b593b21edc07e4d9b49383204d4 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 28 Jul 2026 10:12:11 +0300 Subject: [PATCH] =?UTF-8?q?fix(web-test):=20readTable=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D1=81=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=83?= =?UTF-8?q?=D1=8E=20=D1=88=D0=B0=D0=BF=D0=BA=D1=83=20=D0=BF=D0=BE=20=C2=AB?= =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D0=B0=20/=20=D0=9A=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1С кладёт заголовок группы колонок и её листья в ОДНУ строку шапки, различая их только высотой и шириной. Колонка заводилась на каждый бокс с текстом, а строка ключуется по имени — поэтому одноимённые листья соседних групп («План» под «Цена» и под «Количество») схлопывались в один ключ и склеивались через " / ", а заголовки групп становились пустыми колонками. Заголовок группы отличается от настоящей широкой колонки над узкими (паттерн «Исполнитель» над «Срок»/«Выполнена») одним надёжным признаком: его colindex не встречается ни в одной ячейке тела. По нему листья переименовываются в «Группа / Лист» — той же конвенцией, что уже применяет читалка табличного документа, — а заголовок из колонок убирается. Разворот объединённой шапки («Субконто 1/2/3») не задет: под ним боксов-листьев в шапке нет. Заодно у пути записи заголовок ячейки резолвился x-сканом шапки и под группой отдавал «Цена» всем трём листьям — fillTableRow по имени из readTable отвечал notFilled. Теперь имя берётся из общей модели, как в чтении и клике. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/web-test/SKILL.md | 2 + .../skills/web-test/scripts/dom/_shared.mjs | 54 +++++++++++- .../skills/web-test/scripts/dom/grid-edit.mjs | 45 +++++----- .../integration/build-webtest-config.test.mjs | 83 +++++++++++++++++++ tests/web-test/24-multirow-header.test.mjs | 77 ++++++++++++++++- 5 files changed, 238 insertions(+), 23 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 8b032083..cc3a123d 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -191,6 +191,8 @@ if (t.rows[0]['Присоединенные файлы']) { /* has an attached f t.rows[0]['ЭДО'] === 'pic:1'; // connected to 1С-ЭДО ('pic:0' = not) ``` +**Grouped headers.** Columns merged under a group caption are reported with that caption: `'Цена / План'`, `'Цена / Факт'` — the caption alone is not a data column. Such names also work in `clickElement({row, column})` and `fillTableRow`; a short name (`'Факт'`) resolves too, picking the leftmost match. + Special row fields: - `_kind: 'group'` — hierarchical group row - `_kind: 'parent'` — parent row in hierarchy diff --git a/.claude/skills/web-test/scripts/dom/_shared.mjs b/.claude/skills/web-test/scripts/dom/_shared.mjs index ea081385..67635c56 100644 --- a/.claude/skills/web-test/scripts/dom/_shared.mjs +++ b/.claude/skills/web-test/scripts/dom/_shared.mjs @@ -1,4 +1,4 @@ -// web-test dom shared v1.8 — embedded JS function constants +// web-test dom shared v1.9 — embedded JS function constants // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Shared function strings embedded into page.evaluate() generators. @@ -259,6 +259,35 @@ function buildColumnModel(grid) { columns.push(Object.assign(base, { name: name, text: '', title: title, kind: kind })); }); + // Column GROUPS («Цена» over «План»/«Факт»/«Откл.»). 1С puts the group caption and its leaves + // into the SAME head line, differing by y and width. A group caption is not a column: it has no + // cells of its own, and leaf names repeat across groups («План» under both «Цена» and + // «Количество») — keyed by bare name, values of different groups collided into one key. + // The caption is told apart from a genuine wide column (pattern «Исполнитель» over «Срок»/ + // «Выполнена», see 24-multirow-header) by ONE reliable fact: its colindex never appears among + // body cells. Geometry alone cannot tell them apart — both sit above narrower boxes. + // Leaves are renamed «Группа / Лист», the same convention the spreadsheet reader uses. + const bodyCi = new Set(); + lines.slice(0, 5).forEach(line => { + [...line.children].forEach(b => { + if (b.offsetWidth === 0) return; + const ci = b.getAttribute('colindex'); + if (ci != null) bodyCi.add(ci); + }); + }); + const covers = (g, c) => { const cx = c.x + (c.right - c.x) / 2; return c.y > g.y && cx >= g.x && cx < g.right; }; + const groupHdrs = columns.filter(g => g.ci != null && !bodyCi.has(g.ci) && columns.some(c => c !== g && covers(g, c))); + if (groupHdrs.length) { + columns.forEach(c => { + if (groupHdrs.indexOf(c) >= 0) return; + const parents = groupHdrs.filter(g => covers(g, c)).sort((a, b) => a.y - b.y); + if (!parents.length) return; + c.name = parents.map(g => g.text).concat(c.name).join(' / '); + c.group = parents.map(g => g.text).join(' / '); + }); + groupHdrs.forEach(g => { const at = columns.indexOf(g); if (at >= 0) columns.splice(at, 1); }); + } + 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); }); @@ -637,7 +666,7 @@ function readForm(p) { const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; if (text) { const r = box.getBoundingClientRect(); - columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height }); + columns.push({ text, ci: box.getAttribute('colindex'), x: r.x, right: r.x + r.width, y: r.y, h: r.height }); } else { // Unnamed column — check if data cells contain checkboxes const firstLine = body?.querySelector('.gridLine'); @@ -651,6 +680,27 @@ function readForm(p) { } } }); + // Column groups → «Группа / Лист». Mirrors buildColumnModel: a group caption owns no + // cells, so its colindex is absent from the body; leaf names repeat across groups. + const dataLines = [...(body?.querySelectorAll('.gridLine') || [])].slice(0, 5); + if (dataLines.length && columns.length > 0) { + const bodyCi = new Set(); + dataLines.forEach(line => [...line.children].forEach(b => { + if (b.offsetWidth === 0) return; + const ci = b.getAttribute('colindex'); + if (ci != null) bodyCi.add(ci); + })); + const covers = (g, c) => { const cx = c.x + (c.right - c.x) / 2; return c.y > g.y && cx >= g.x && cx < g.right; }; + const grpHdrs = columns.filter(g => g.ci != null && !bodyCi.has(g.ci) && columns.some(c => c !== g && covers(g, c))); + if (grpHdrs.length) { + columns.forEach(c => { + if (grpHdrs.indexOf(c) >= 0) return; + const parents = grpHdrs.filter(g => covers(g, c)).sort((a, b) => a.y - b.y); + if (parents.length) c.text = parents.map(g => g.text).concat(c.text).join(' / '); + }); + grpHdrs.forEach(g => { const at = columns.indexOf(g); if (at >= 0) columns.splice(at, 1); }); + } + } // Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3) const firstLine = body?.querySelector('.gridLine'); if (firstLine && columns.length > 0) { diff --git a/.claude/skills/web-test/scripts/dom/grid-edit.mjs b/.claude/skills/web-test/scripts/dom/grid-edit.mjs index 04aac211..95a123c6 100644 --- a/.claude/skills/web-test/scripts/dom/grid-edit.mjs +++ b/.claude/skills/web-test/scripts/dom/grid-edit.mjs @@ -1,7 +1,7 @@ -// web-test dom/grid-edit v1.3 — DOM scripts for row-fill (grid edit-time operations) +// web-test dom/grid-edit v1.4 — DOM scripts for row-fill (grid edit-time operations) // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // -import { HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs'; +import { 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 @@ -26,23 +26,16 @@ function gridResolver(gridSelector) { */ export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) { return `(() => { - ${HEADERLESS_GRID_FN} + ${COLUMN_MODEL_FN} const grid = ${gridResolver(gridSelector)}; if (!grid) return null; - const head = grid.querySelector('.gridHead'); + // Names come from the shared model (headed and headerless alike), so a key written the way + // readTable reports it — «Цена / Факт» — sorts into its real position instead of the tail. const cols = []; - if (head) { - const headLine = head.querySelector('.gridLine') || head; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); - const ci = parseInt(box.getAttribute('colindex') || '-1'); - if (t) cols.push({ text: t, colindex: ci }); - }); - } else { - // Headerless: synthesized columns (КолонкаN/(checkbox)) ordered by colindex - synthHeaderlessColumns(grid).forEach(c => cols.push({ text: c.name.toLowerCase(), colindex: parseInt(c.colindex) })); - } + buildColumnModel(grid).columns.forEach(c => { + if (!c.name) return; + cols.push({ text: c.name.toLowerCase(), colindex: c.ci != null ? parseInt(c.ci) : 999 }); + }); const keys = ${JSON.stringify(fieldKeys)}; const mapped = keys.map(k => { const exact = cols.find(c => c.text === k); @@ -233,7 +226,7 @@ export function getGridEditCheckScript() { */ export function readActiveGridCellScript() { return `(() => { - ${HEADERLESS_GRID_FN} + ${COLUMN_MODEL_FN} const f = document.activeElement; if (!f) return { tag: 'none' }; if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { @@ -244,8 +237,22 @@ export function readActiveGridCellScript() { if (grid) { const fr = f.getBoundingClientRect(); const head = grid.querySelector('.gridHead'); + // Column name from the shared model — same naming as readTable, including + // «Группа / Колонка» under a grouped header. The editing INPUT sits in an overlay, + // so the cell is located by x against the first body line, then by its colindex. + const model = buildColumnModel(grid); + const bLine = grid.querySelector('.gridBody .gridLine'); + if (bLine) for (const b of bLine.children) { + if (b.offsetWidth === 0) continue; + const br = b.getBoundingClientRect(); + if (fr.x >= br.x && fr.x < br.x + br.width) { + const ci = b.getAttribute('colindex'); + if (ci != null && model.byCi[ci]) headerText = model.byCi[ci].name; + break; + } + } const hl = head?.querySelector('.gridLine') || head; - if (hl) for (const h of hl.children) { + if (!headerText && hl) for (const h of hl.children) { if (h.offsetWidth === 0) continue; const hr = h.getBoundingClientRect(); if (fr.x >= hr.x && fr.x < hr.x + hr.width) { @@ -254,7 +261,7 @@ export function readActiveGridCellScript() { break; } } - if (!head) { + if (!headerText && !head) { // Headerless: the editing INPUT is rendered in an overlay (.inputs) OUTSIDE // the .gridBox, so walking ancestors for colindex fails. Resolve colindex by // matching the input's x against the body cells (same idea as the headed branch). diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index 5d128555..e8ccdd97 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -558,6 +558,20 @@ export const steps = [ validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'DataProcessors/МногострочнаяШапка' }, }, + // Обработка ГруппыКолонок — грид, где шапка сгруппирована ГОРИЗОНТАЛЬНО: родительский + // заголовок группы («Цена», «Количество») сверху, под ним листья с ОДИНАКОВЫМИ именами + // («План», «Факт») в каждой группе. Отличие от МногострочнаяШапка: родитель здесь — + // чистый заголовок группы, своих ячеек в теле у него нет. + { + name: 'meta-compile: Обработка ГруппыКолонок', + script: 'meta-compile/scripts/meta-compile', + input: { + type: 'DataProcessor', name: 'ГруппыКолонок', + }, + args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' }, + validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'DataProcessors/ГруппыКолонок' }, + }, + // Обработка ПроверкаДоступности — форма с парами «доступный / недоступный» // элементов каждого типа (кнопка командной панели, обычная кнопка, поле ввода, // флажок, переключатель). Недоступность задаётся декларативно (disabled:true → @@ -1809,6 +1823,73 @@ export const steps = [ `, }, + // Обработка ГруппыКолонок — форма с горизонтальными группами колонок в шапке. + { + name: 'form-add: Форма ГруппыКолонок', + script: 'form-add/scripts/form-add', + args: { '-ObjectPath': '{workDir}/DataProcessors/ГруппыКолонок.xml', '-FormName': 'ФормаОбработки' }, + }, + { + name: 'form-compile: Форма ГруппыКолонок', + script: 'form-compile/scripts/form-compile', + input: { + title: 'Группы колонок', + events: { OnCreateAtServer: 'ПриСозданииНаСервере' }, + attributes: [ + { name: 'Объект', type: 'DataProcessorObject.ГруппыКолонок', main: true }, + { name: 'Таблица', type: 'ValueTable', columns: [ + { name: 'Код', type: 'String(10)', title: 'Код' }, + { name: 'Товар', type: 'String(50)', title: 'Товар' }, + { name: 'ЦенаПлан', type: 'String(50)', title: 'План' }, + { name: 'ЦенаФакт', type: 'String(50)', title: 'Факт' }, + { name: 'ЦенаОткл', type: 'String(50)', title: 'Откл.' }, + { name: 'КолПлан', type: 'String(50)', title: 'План' }, + { name: 'КолФакт', type: 'String(50)', title: 'Факт' }, + ]}, + ], + elements: [ + { table: 'Таблица', path: 'Таблица', changeRowSet: true, columns: [ + { input: 'Код', path: 'Таблица.Код', title: 'Код', width: 8 }, + { input: 'Товар', path: 'Таблица.Товар', title: 'Товар', width: 20 }, + // Родитель «Цена» — ЧИСТЫЙ заголовок группы (в теле своих ячеек нет), под ним три + // листа. Имена листьев повторяются в соседней группе — на этом readTable схлопывал + // значения в один ключ через ' / '. + { columnGroup: 'horizontal', name: 'ГруппаЦена', title: 'Цена', showInHeader: true, children: [ + { input: 'ЦенаПлан', path: 'Таблица.ЦенаПлан', title: 'План', width: 12 }, + { input: 'ЦенаФакт', path: 'Таблица.ЦенаФакт', title: 'Факт', width: 12 }, + { input: 'ЦенаОткл', path: 'Таблица.ЦенаОткл', title: 'Откл.', width: 12 }, + ]}, + { columnGroup: 'horizontal', name: 'ГруппаКоличество', title: 'Количество', showInHeader: true, children: [ + { input: 'КолПлан', path: 'Таблица.КолПлан', title: 'План', width: 12 }, + { input: 'КолФакт', path: 'Таблица.КолФакт', title: 'Факт', width: 12 }, + ]}, + ]}, + ], + }, + args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/DataProcessors/ГруппыКолонок/Forms/ФормаОбработки/Ext/Form.xml' }, + validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'DataProcessors/ГруппыКолонок/Forms/ФормаОбработки/Ext/Form.xml' }, + }, + { + name: 'writeFile: ГруппыКолонок form Module.bsl', + writeFile: 'DataProcessors/ГруппыКолонок/Forms/ФормаОбработки/Ext/Form/Module.bsl', + content: `&НаСервере +Процедура ПриСозданииНаСервере(Отказ, СтандартнаяОбработка) +\t// Значения уникальны по колонкам: промах резолвера виден сразу. +\tДля Сч = 1 По 3 Цикл +\t\tС = Формат(Сч, "ЧН=; ЧГ="); +\t\tСтрока = Таблица.Добавить(); +\t\tСтрока.Код = "К" + С; +\t\tСтрока.Товар = "Товар " + С; +\t\tСтрока.ЦенаПлан = "Ц-план " + С; +\t\tСтрока.ЦенаФакт = "Ц-факт " + С; +\t\tСтрока.ЦенаОткл = "Ц-откл " + С; +\t\tСтрока.КолПлан = "К-план " + С; +\t\tСтрока.КолФакт = "К-факт " + С; +\tКонецЦикла; +КонецПроцедуры +`, + }, + // ── 4. DCS for report ── // Сначала добавляем макет ОсновнаяСхемаКомпоновкиДанных к отчёту (регистрируется // в Reports/ОстаткиТоваров.xml + автоматически выставляется MainDataCompositionSchema), @@ -1873,6 +1954,7 @@ export const steps = [ 'DataProcessor.МножественныйВыбор', 'DataProcessor.БезшапочнаяТаблица', 'DataProcessor.МногострочнаяШапка', + 'DataProcessor.ГруппыКолонок', ], }, args: { '-DefinitionFile': '{inputFile}', '-OutputDir': '{workDir}' }, @@ -1915,6 +1997,7 @@ export const steps = [ 'DataProcessor.МножественныйВыбор: Use View', 'DataProcessor.БезшапочнаяТаблица: Use View', 'DataProcessor.МногострочнаяШапка: Use View', + 'DataProcessor.ГруппыКолонок: Use View', 'DataProcessor.ПроверкаДоступности: Use View', 'DataProcessor.СтраницаНастроек: Use View', ], diff --git a/tests/web-test/24-multirow-header.test.mjs b/tests/web-test/24-multirow-header.test.mjs index aaa3fe81..d37d3de7 100644 --- a/tests/web-test/24-multirow-header.test.mjs +++ b/tests/web-test/24-multirow-header.test.mjs @@ -1,6 +1,6 @@ export const name = 'multirow-header: резолвинг колонок на двухэтажной шапке (чтение/клик/заполнение)'; export const tags = ['table', 'columns']; -export const timeout = 120000; +export const timeout = 180000; // Стенд «Многострочная шапка» воспроизводит два паттерна, снятых живьём с ERP: // @@ -12,8 +12,12 @@ export const timeout = 120000; // 2. паттерн «Операция» — шапка только у группы «Субконто», а ячеек под ней три и своих // шапок у них НЕТ. Здесь разворот в «Субконто 1/2/3» — правильное поведение, его // обязана сохранить любая правка. +// +// Вторая часть теста — стенд «Группы колонок»: горизонтальные ГруппыКолонок («Цена», «Количество») +// с ОДИНАКОВЫМИ именами листьев («План»/«Факт»). Родитель здесь, в отличие от «Исполнитель», +// собственных ячеек не имеет — его colindex отсутствует в теле. Листья именуются «Группа / Лист». -export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillTableRow, getPage, assert, step, log }) { +export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillTableRow, getFormState, getPage, assert, step, log }) { // Куда клик попал НА САМОМ ДЕЛЕ. Результат clickElement возвращает запрошенное имя колонки // (эхо), а не разрешённую ячейку, — по нему промах неотличим от попадания. Нажатая ячейка @@ -121,6 +125,75 @@ export default async function({ navigateSection, openCommand, clickElement, clos assert.equal(row['Субконто 3'], 'Субконто3 1', '«Субконто 3» не задета'); }); + await step('setup: перейти на стенд «Группы колонок»', async () => { + await closeForm(); + await navigateSection('Склад'); + await openCommand('Группы колонок'); + }); + + await step('groups: листья именуются «Группа / Лист», значения не склеены', async () => { + const t = await readTable(); + log(`columns=${JSON.stringify(t.columns)}`); + log(`row0=${JSON.stringify(t.rows[0])}`); + + ['Цена / План', 'Цена / Факт', 'Цена / Откл.', 'Количество / План', 'Количество / Факт'] + .forEach(c => assert.includes(t.columns, c, `колонка «${c}»`)); + // Голых имён быть не должно: они одинаковы в обеих группах, и строка ключуется по имени — + // именно на этом значения «Цена»/«Количество» схлопывались в один ключ через ' / '. + assert.ok(!t.columns.includes('План'), `голой «План» быть не должно (columns=${JSON.stringify(t.columns)})`); + assert.ok(!t.columns.includes('Факт'), `голой «Факт» быть не должно (columns=${JSON.stringify(t.columns)})`); + // Заголовок группы — не колонка данных: своих ячеек у него нет. + assert.ok(!t.columns.includes('Цена'), 'заголовок группы «Цена» не колонка'); + assert.ok(!t.columns.includes('Количество'), 'заголовок группы «Количество» не колонка'); + + const r = t.rows[0]; + assert.equal(r['Цена / План'], 'Ц-план 1', 'Цена / План'); + assert.equal(r['Цена / Факт'], 'Ц-факт 1', 'Цена / Факт'); + assert.equal(r['Цена / Откл.'], 'Ц-откл 1', 'Цена / Откл.'); + assert.equal(r['Количество / План'], 'К-план 1', 'Количество / План'); + assert.equal(r['Количество / Факт'], 'К-факт 1', 'Количество / Факт'); + assert.equal(r['Код'], 'К1', 'обычная колонка не задета'); + }); + + await step('groups: getFormState().tables[] даёт те же имена, что readTable', async () => { + // У form-state своя ветка вывода колонок — она обязана совпадать с моделью readTable, + // иначе имя из tables[] не годится для клика. + const t = await readTable(); + const f = await getFormState(); + log(`tables=${JSON.stringify(f.tables)}`); + assert.deepEqual(f.tables[0].columns, t.columns, 'columns из getFormState == columns из readTable'); + }); + + await step('groups: клик по полному имени попадает в нужную группу', async () => { + const res = await clickElement({ row: { 'Код': 'К2' }, column: 'Количество / Факт' }); + assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell'); + const cell = await focusedCell(); + log(`focused: ${JSON.stringify(cell)}`); + assert.equal(cell?.text, 'К-факт 2', `клик попал в «Количество / Факт» (got «${cell?.text}»)`); + }); + + await step('groups: короткое имя резолвится через суффикс «Группа / Имя»', async () => { + const res = await clickElement({ row: { 'Код': 'К2' }, column: 'Откл.' }); + assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell'); + const cell = await focusedCell(); + log(`focused: ${JSON.stringify(cell)}`); + assert.equal(cell?.text, 'Ц-откл 2', `«Откл.» нашлось как «Цена / Откл.» (got «${cell?.text}»)`); + }); + + await step('groups: fillTableRow пишет по имени из readTable', async () => { + // Путь записи матчит ячейку по её техническому имени и по заголовку. Заголовок листа под + // группой резолвился x-скáном и давал «Цена» для всех трёх листьев — имя из readTable не + // находилось вовсе (notFilled). + const r = await fillTableRow({ 'Цена / Факт': 'изменён' }, { row: { 'Код': 'К3' } }); + log(`filled=${JSON.stringify(r.filled)} notFilled=${JSON.stringify(r.notFilled)}`); + assert.ok(!r.notFilled, `все поля найдены (notFilled=${JSON.stringify(r.notFilled)})`); + const row = (await readTable()).rows.find(x => x['Код'] === 'К3'); + log(`after fill: ${JSON.stringify(row)}`); + assert.equal(row['Цена / Факт'], 'изменён', 'значение попало в «Цена / Факт»'); + assert.equal(row['Цена / План'], 'Ц-план 3', 'сосед по группе не задет'); + assert.equal(row['Количество / Факт'], 'К-факт 3', 'одноимённый лист соседней группы не задет'); + }); + await step('cleanup: закрыть форму', async () => { await closeForm(); });