fix(web-test): заголовок формы в состоянии + formHasField по массиву fields

Два задокументированных ассерта бросали ВСЕГДА, то есть были мертвы:

- formTitle читал state.title, которого не заполнял никто: getFormStateScript
  собирал форму без заголовка. Единственным носителем оставалась панель
  открытых окон (activeTab), а она отключается в настройках 1С — на такой базе
  заголовок был недоступен ничем.
- formHasField читал state.fields[name], хотя fields — массив объектов
  {name, value, …}. На массиве это всегда undefined.

getFormState теперь отдаёт title. Берётся он из шапки самой формы: заголовок
лежит в атрибуте (title у .toplineBoxTitle, data-title у родителя), сам элемент
пустой — поэтому поиском по тексту он и не находился.

Выбор шапки — не «первая видимая»: при открытом всплывающем окне видимы ДВЕ,
родителя и окна, и наивное правило отдавало заголовок родителя — правдоподобный
неверный ответ, при котором тест «окно выбора открылось» зеленел бы по
документу. Приоритет взят тот же, что уже отлажен для крестика закрытия в
closeCrossScript: плавающее окно ps<N> с наибольшим индексом → собственная шапка
формы → и только потом панель открытых окон. Привязка к 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) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-20 18:51:12 +03:00
co-authored by Claude Opus 4.8
parent 9b65dccd8a
commit 8826a88427
8 changed files with 66 additions and 19 deletions
+3 -1
View File
@@ -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. "Основное", "Объекты метаданных", "Подсистемы").
+5 -5
View File
@@ -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));
}
```
@@ -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 || [];
@@ -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) {
@@ -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:
// <div class="toplineBox" data-title="Контрагенты">
// <div id="VW_page1headerTopLine_title" class="toplineBoxTitle" title="Контрагенты"></div>
// Header numbering (VW_page<M>) does not match form numbering (form<N>), 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<N>, 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;
+7 -5
View File
@@ -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));
}
```
+1 -1
View File
@@ -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();
+9
View File
@@ -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 });