From 8826a8842730ff43daae9f76556c8bf78a17dbe6 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 20 Jul 2026 18:50:43 +0300 Subject: [PATCH] =?UTF-8?q?fix(web-test):=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=BA=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20+=20formHasField=20=D0=BF=D0=BE=20=D0=BC=D0=B0=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D0=B2=D1=83=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два задокументированных ассерта бросали ВСЕГДА, то есть были мертвы: - formTitle читал state.title, которого не заполнял никто: getFormStateScript собирал форму без заголовка. Единственным носителем оставалась панель открытых окон (activeTab), а она отключается в настройках 1С — на такой базе заголовок был недоступен ничем. - formHasField читал state.fields[name], хотя fields — массив объектов {name, value, …}. На массиве это всегда undefined. getFormState теперь отдаёт title. Берётся он из шапки самой формы: заголовок лежит в атрибуте (title у .toplineBoxTitle, data-title у родителя), сам элемент пустой — поэтому поиском по тексту он и не находился. Выбор шапки — не «первая видимая»: при открытом всплывающем окне видимы ДВЕ, родителя и окна, и наивное правило отдавало заголовок родителя — правдоподобный неверный ответ, при котором тест «окно выбора открылось» зеленел бы по документу. Приоритет взят тот же, что уже отлажен для крестика закрытия в closeCrossScript: плавающее окно ps с наибольшим индексом → собственная шапка формы → и только потом панель открытых окон. Привязка к id, а не к тексту — не ломается на другой локали. Панель осталась последним звеном: она отключаема, а при всплывающем окне ещё и показывает родителя. Диагностика раннера (resetState) тоже переведена на title с прежним activeTab как запасным. formHasField ищет по массиву и перечисляет доступные имена в ошибке (раньше Object.keys по массиву давал индексы). formTitle отличает «заголовок недоступен» (title === null) от несовпадения. Почему не поймали раньше: из 12 ассертов сюита вызывала 8, и оба сломанных были среди четырёх невызываемых. Теперь все четыре задействованы на настоящем выводе getFormState — formTitle/formHasField/noErrors в 12-formstate (включая случай всплывающего окна), tableRowCount в 09-filter. Отдельного юнит-теста намеренно нет: состояние для него пришлось бы писать руками, а именно неверное представление о форме состояния и породило оба дефекта. Доки приведены к массиву: примеры вида s.fields['X']?.value в regress.md и в спеке заменены на fields.find(f => f.name === 'X'). Проверено: заголовок на списке, форме элемента и всплывающем окне; каскад разведён по значениям (шапка выигрывает у панели, при пустой шапке — откат); позитив и негатив всех четырёх ассертов на реальном состоянии формы; полный регресс 29/29 до и после, file/name/status идентичны. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/web-test/SKILL.md | 4 +++- .claude/skills/web-test/regress.md | 10 ++++---- .../scripts/cli/test-runner/assertions.mjs | 18 +++++++++++--- .../scripts/cli/test-runner/discover.mjs | 6 +++-- .../web-test/scripts/dom/form-state.mjs | 24 +++++++++++++++++-- docs/web-test-regression-spec.md | 12 ++++++---- tests/web-test/09-filter.test.mjs | 2 +- tests/web-test/12-formstate.test.mjs | 9 +++++++ 8 files changed, 66 insertions(+), 19 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 1a3b1922..8b032083 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -129,7 +129,7 @@ Switch to an already-open tab/window (fuzzy match). ### Reading form state -#### `getFormState()` → `{ form, formCount, openForms, fields, buttons, tabs, navigation?, table, tables, filters, reportSettings? }` +#### `getFormState()` → `{ form, formCount, openForms, title, fields, buttons, tabs, navigation?, table, tables, filters, reportSettings? }` Returns current form structure. This is the primary way to understand what's on screen. **form** — active form number, or `null` when no form is open (desktop). @@ -142,6 +142,8 @@ Returns current form structure. This is the primary way to understand what's on **openTabs** — array of `{ name, active? }` from the open-windows tab bar. Only present when the tab bar is enabled in 1C settings. Do NOT rely on this — use `formCount`/`openForms` instead. +**title** — caption of the active form (`"Контрагенты"`, `"Заказ поставщику ТД00-000052 от 05.07.2022"`). Read from the form's own header, which does not depend on the open-windows tab bar; when the form shows no header, falls back to the active tab's caption, and is `null` when neither is available. + **fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields), `disabled?` (control is unavailable). `buttons[]` carry `disabled?` too. **navigation** — form navigation panel links (for objects with subordinate catalogs): `[{ name, active? }]`. Clickable via `clickElement()`. Only present when the form has a navigation panel (e.g. "Основное", "Объекты метаданных", "Подсистемы"). diff --git a/.claude/skills/web-test/regress.md b/.claude/skills/web-test/regress.md index e72f000c..fec0eb76 100644 --- a/.claude/skills/web-test/regress.md +++ b/.claude/skills/web-test/regress.md @@ -186,8 +186,8 @@ assert.match(string, regex, msg?) // regex.test(string) await assert.throws(asyncFn, msg?) // passes if fn throws (use await) // 1C-specific — operate on getFormState() / readTable() output -assert.formHasField(state, 'Контрагент', msg?) // state.fields[name] exists -assert.formTitle(state, expected, msg?) // state.title includes expected +assert.formHasField(state, 'Контрагент', msg?) // fields[] contains a field with that name +assert.formTitle(state, expected, msg?) // state.title includes expected (null title → fails saying so) assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool // object form: { 'Наименование': 'Тест' } // fn form: r => r['Сумма'] > 100 @@ -318,7 +318,7 @@ export default async function({ clerk, manager, step, assert }) { }); await step('Кладовщик видит новый статус', async () => { const s = await clerk.getFormState(); - assert.equal(s.fields['Статус']?.value, 'Утверждён'); + assert.equal(s.fields.find(f => f.name === 'Статус')?.value, 'Утверждён'); }); await step('Освободить сессию кладовщика', async () => { await manager.closeContext('clerk'); // free a 1C license for the next test @@ -341,7 +341,7 @@ export default async function({ openCommand, clickElement, getFormState, assert, await clickElement('Создать'); await clickElement('Провести'); const s = await getFormState(); - assert.ok(s.errorModal || s.fields['Контрагент']?.required, + assert.ok(s.errorModal || s.fields.find(f => f.name === 'Контрагент')?.required, 'Должна быть ошибка валидации или поле помечено обязательным'); } ``` @@ -361,7 +361,7 @@ export const params = [ export default async function({ fillFields, getFormState, assert }, { type, field, value }) { await fillFields({ [field]: value }); const state = await getFormState(); - assert.equal(state.fields[field]?.value, String(value)); + assert.equal(state.fields.find(f => f.name === field)?.value, String(value)); } ``` diff --git a/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs b/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs index 23afb2d0..720e1633 100644 --- a/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs +++ b/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs @@ -1,4 +1,4 @@ -// web-test cli/test-runner/assertions v1.0 — ctx.assert API +// web-test cli/test-runner/assertions v1.1 — ctx.assert API // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills export function createAssertions() { @@ -37,11 +37,23 @@ export function createAssertions() { throw new AssertionError(msg || 'Expected function to throw'); }, // 1C-specific + // `fields` is an ARRAY of { name, value, ... } — indexing it by field name yields undefined, + // which used to make this assertion throw on every call, including the valid ones. formHasField(state, fieldName, msg) { - if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName); + const names = (state?.fields || []).map(f => f.name); + if (!names.includes(fieldName)) { + throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${names.join(', ')}`, null, fieldName); + } }, formTitle(state, expected, msg) { - if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected); + // `title` is null when the form exposes no caption and the open-windows panel is off — + // say so instead of reporting a mismatch against "null". + if (state?.title == null) { + throw new AssertionError(msg || `Form title is not available (state.title is null), expected it to contain "${expected}"`, null, expected); + } + if (!state.title.includes(expected)) { + throw new AssertionError(msg || `Form title "${state.title}" does not contain "${expected}"`, state.title, expected); + } }, tableHasRow(table, predicate, msg) { const rows = table?.rows || []; diff --git a/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs index 76650fb0..b1c669b0 100644 --- a/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs +++ b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs @@ -1,4 +1,4 @@ -// web-test cli/test-runner/discover v1.3 — test file discovery + state reset between tests +// web-test cli/test-runner/discover v1.4 — test file discovery + state reset between tests // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { existsSync, readdirSync } from 'fs'; import { resolve } from 'path'; @@ -73,7 +73,9 @@ export async function resetState(ctx) { return { clean: false, attempts, lastError, form: state.form, - title: state.activeTab || null, + // state.title is the form's own caption; activeTab reads the open-windows panel, which the + // user can switch off — keep it only as the fallback it always was. + title: state.title || state.activeTab || null, modal: !!state.modal, }; } catch (e) { diff --git a/.claude/skills/web-test/scripts/dom/form-state.mjs b/.claude/skills/web-test/scripts/dom/form-state.mjs index 346471e0..b284bf2b 100644 --- a/.claude/skills/web-test/scripts/dom/form-state.mjs +++ b/.claude/skills/web-test/scripts/dom/form-state.mjs @@ -1,4 +1,4 @@ -// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs +// web-test dom/form-state v1.1 — combined detectForm + readForm + open tabs + form caption // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs'; @@ -26,7 +26,27 @@ export function getFormStateScript() { openTabs.push(entry); }); const activeTab = openTabs.find(t => t.active)?.name || null; - const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData }; + // Caption of the ACTIVE form. Lives in an attribute, not in text — the div itself is empty: + //
+ //
+ // Header numbering (VW_page) does not match form numbering (form), so the header cannot + // be picked by form number. Several headers can be visible at once — with a selection form up, + // BOTH the parent form's header and the pop-up's are visible — so "first visible" would report + // the parent's caption for the pop-up: a plausible, wrong answer. + // Priority is therefore the one already measured for the close cross (dom/forms.mjs + // closeCrossScript): floating window (ps, highest index = topmost) → the form's own header → + // and only then the open-windows tab, which the user can switch off in 1C settings. + // Anchored on ids, not on the visible text, so a non-Russian locale keeps working. + const heads = [...document.querySelectorAll('[id*="headerTopLine_title"]')] + .filter(e => e.offsetWidth > 0 && e.offsetHeight > 0); + const floating = heads.filter(e => /ps\\d+headerTopLine_title$/.test(e.id)); + const own = heads.filter(e => /^VW_page\\d+headerTopLine_title$/.test(e.id)); + const head = floating.pop() || own.pop() || null; + let title = head + ? (head.getAttribute('title') || head.parentElement?.getAttribute('data-title') || null) + : null; + if (!title) title = activeTab; + const result = { form: formNum, activeTab, title, openForms: meta.allForms, formCount: meta.formCount, ...formData }; if (meta.modal) result.modal = true; if (openTabs.length) result.openTabs = openTabs; return result; diff --git a/docs/web-test-regression-spec.md b/docs/web-test-regression-spec.md index defb3891..8b2dae93 100644 --- a/docs/web-test-regression-spec.md +++ b/docs/web-test-regression-spec.md @@ -306,11 +306,13 @@ await assert.throws(asyncFn, msg?) // ожидает исключение ```js assert.formHasField(state, fieldName, msg?) -// проверяет наличие state.fields[fieldName]; в сообщении об ошибке -// перечисляются доступные поля для быстрой диагностики +// проверяет, что в массиве state.fields есть поле с таким name; +// в сообщении об ошибке перечисляются доступные поля для быстрой диагностики assert.formTitle(state, expected, msg?) -// проверяет, что state.title содержит expected +// проверяет, что state.title СОДЕРЖИТ expected (подстрока, не строгое равенство). +// state.title — заголовок активной формы: сначала из шапки формы, при её отсутствии — +// из панели открытых окон; null, если недоступны оба (тогда ассерт падает с этим фактом) assert.tableHasRow(table, predicate, msg?) // predicate: объект (частичное совпадение по ===) или функция row => bool @@ -609,7 +611,7 @@ await step('Менеджер утверждает', async () => { await step('Кладовщик проверяет статус', async () => { // страница кладовщика ТА ЖЕ — форма открыта, навигация не нужна const state = await clerk.getFormState(); - assert.equal(state.fields['Статус']?.value, 'Утверждён'); + assert.equal(state.fields.find(f => f.name === 'Статус')?.value, 'Утверждён'); }); ``` @@ -918,7 +920,7 @@ export const params = [ export default async function({ fillFields, getFormState, assert }, { type, field, value }) { await fillFields({ [field]: value }); const state = await getFormState(); - assert.equal(state.fields[field]?.value, String(value)); + assert.equal(state.fields.find(f => f.name === field)?.value, String(value)); } ``` diff --git a/tests/web-test/09-filter.test.mjs b/tests/web-test/09-filter.test.mjs index 6df7b5b6..ea29ab00 100644 --- a/tests/web-test/09-filter.test.mjs +++ b/tests/web-test/09-filter.test.mjs @@ -40,7 +40,7 @@ export default async function({ navigateSection, openCommand, filterList, unfilt await filterList('ООО Север', { field: 'Наименование', exact: true }); const t = await readTable({ maxRows: 50 }); log(`exact 'ООО Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`); - assert.equal(t.rows?.length, 1, 'exact:true должен дать строго 1 совпадение'); + assert.tableRowCount(t, 1, 'exact:true должен дать строго 1 совпадение'); assert.equal(t.rows[0]['Наименование'], 'ООО Север', 'Это должно быть ООО Север'); await unfilterList(); await closeForm(); diff --git a/tests/web-test/12-formstate.test.mjs b/tests/web-test/12-formstate.test.mjs index a917ba26..f675e36e 100644 --- a/tests/web-test/12-formstate.test.mjs +++ b/tests/web-test/12-formstate.test.mjs @@ -14,6 +14,10 @@ export default async function({ navigateSection, openCommand, clickElement, clos assert.ok(s.tables?.length >= 1, 'На форме списка есть таблица'); assert.ok(s.tables[0].columns?.length >= 2, 'У таблицы есть колонки'); assert.ok(s.buttons?.length >= 1, 'На форме есть кнопки'); + // title читается из шапки формы (атрибут .toplineBoxTitle), а не из панели открытых окон — + // она отключаема в настройках 1С. Панель остаётся запасным источником. + assert.formTitle(s, 'Контрагенты', 'Заголовок формы списка'); + assert.noErrors(s, 'На только что открытой форме списка ошибок быть не должно'); await closeForm(); }); @@ -26,6 +30,8 @@ export default async function({ navigateSection, openCommand, clickElement, clos assert.ok(s.fields?.length >= 1, 'На форме элемента есть поля'); const named = s.fields.find(f => f.name === 'Наименование'); log(`Наименование: label='${named?.label}' value='${named?.value}'`); + assert.formHasField(s, 'Наименование', 'formHasField ищет по массиву fields, а не по ключу'); + assert.formTitle(s, 'Контрагент', 'Заголовок формы элемента'); assert.ok(named, 'Должно быть поле Наименование'); assert.equal(named.value, 'ООО Север', 'value поля Наименование'); assert.ok(named.label, 'У поля есть label'); @@ -52,6 +58,9 @@ export default async function({ navigateSection, openCommand, clickElement, clos log(`after F4: form=${s.form} formCount=${s.formCount} modal=${s.modal}`); assert.equal(s.modal, true, 'state.modal=true для модальной формы выбора'); assert.ok(s.formCount >= 2, 'formCount >= 2 (родитель + модальная)'); + // Заголовок берётся у всплывающего окна, а не у родителя: видимы обе шапки, а панель + // открытых окон в этот момент показывает родителя ('Приходная накладная'). + assert.formTitle(s, 'Выбор контрагента', 'title всплывающего окна, а не родительской формы'); await closeForm(); await closeForm({ save: false });