mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-21 20:21:02 +03:00
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:
co-authored by
Claude Opus 4.8
parent
9b65dccd8a
commit
8826a88427
@@ -129,7 +129,7 @@ Switch to an already-open tab/window (fuzzy match).
|
|||||||
|
|
||||||
### Reading form state
|
### 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.
|
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).
|
**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.
|
**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.
|
**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. "Основное", "Объекты метаданных", "Подсистемы").
|
**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. "Основное", "Объекты метаданных", "Подсистемы").
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ assert.match(string, regex, msg?) // regex.test(string)
|
|||||||
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
||||||
|
|
||||||
// 1C-specific — operate on getFormState() / readTable() output
|
// 1C-specific — operate on getFormState() / readTable() output
|
||||||
assert.formHasField(state, 'Контрагент', msg?) // state.fields[name] exists
|
assert.formHasField(state, 'Контрагент', msg?) // fields[] contains a field with that name
|
||||||
assert.formTitle(state, expected, msg?) // state.title includes expected
|
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
|
assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool
|
||||||
// object form: { 'Наименование': 'Тест' }
|
// object form: { 'Наименование': 'Тест' }
|
||||||
// fn form: r => r['Сумма'] > 100
|
// fn form: r => r['Сумма'] > 100
|
||||||
@@ -318,7 +318,7 @@ export default async function({ clerk, manager, step, assert }) {
|
|||||||
});
|
});
|
||||||
await step('Кладовщик видит новый статус', async () => {
|
await step('Кладовщик видит новый статус', async () => {
|
||||||
const s = await clerk.getFormState();
|
const s = await clerk.getFormState();
|
||||||
assert.equal(s.fields['Статус']?.value, 'Утверждён');
|
assert.equal(s.fields.find(f => f.name === 'Статус')?.value, 'Утверждён');
|
||||||
});
|
});
|
||||||
await step('Освободить сессию кладовщика', async () => {
|
await step('Освободить сессию кладовщика', async () => {
|
||||||
await manager.closeContext('clerk'); // free a 1C license for the next test
|
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('Создать');
|
||||||
await clickElement('Провести');
|
await clickElement('Провести');
|
||||||
const s = await getFormState();
|
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 }) {
|
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
||||||
await fillFields({ [field]: value });
|
await fillFields({ [field]: value });
|
||||||
const state = await getFormState();
|
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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
export function createAssertions() {
|
export function createAssertions() {
|
||||||
@@ -37,11 +37,23 @@ export function createAssertions() {
|
|||||||
throw new AssertionError(msg || 'Expected function to throw');
|
throw new AssertionError(msg || 'Expected function to throw');
|
||||||
},
|
},
|
||||||
// 1C-specific
|
// 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) {
|
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) {
|
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) {
|
tableHasRow(table, predicate, msg) {
|
||||||
const rows = table?.rows || [];
|
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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { existsSync, readdirSync } from 'fs';
|
import { existsSync, readdirSync } from 'fs';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
@@ -73,7 +73,9 @@ export async function resetState(ctx) {
|
|||||||
return {
|
return {
|
||||||
clean: false, attempts, lastError,
|
clean: false, attempts, lastError,
|
||||||
form: state.form,
|
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,
|
modal: !!state.modal,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} 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
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
||||||
|
|
||||||
@@ -26,7 +26,27 @@ export function getFormStateScript() {
|
|||||||
openTabs.push(entry);
|
openTabs.push(entry);
|
||||||
});
|
});
|
||||||
const activeTab = openTabs.find(t => t.active)?.name || null;
|
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 (meta.modal) result.modal = true;
|
||||||
if (openTabs.length) result.openTabs = openTabs;
|
if (openTabs.length) result.openTabs = openTabs;
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -306,11 +306,13 @@ await assert.throws(asyncFn, msg?) // ожидает исключение
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
assert.formHasField(state, fieldName, msg?)
|
assert.formHasField(state, fieldName, msg?)
|
||||||
// проверяет наличие state.fields[fieldName]; в сообщении об ошибке
|
// проверяет, что в массиве state.fields есть поле с таким name;
|
||||||
// перечисляются доступные поля для быстрой диагностики
|
// в сообщении об ошибке перечисляются доступные поля для быстрой диагностики
|
||||||
|
|
||||||
assert.formTitle(state, expected, msg?)
|
assert.formTitle(state, expected, msg?)
|
||||||
// проверяет, что state.title содержит expected
|
// проверяет, что state.title СОДЕРЖИТ expected (подстрока, не строгое равенство).
|
||||||
|
// state.title — заголовок активной формы: сначала из шапки формы, при её отсутствии —
|
||||||
|
// из панели открытых окон; null, если недоступны оба (тогда ассерт падает с этим фактом)
|
||||||
|
|
||||||
assert.tableHasRow(table, predicate, msg?)
|
assert.tableHasRow(table, predicate, msg?)
|
||||||
// predicate: объект (частичное совпадение по ===) или функция row => bool
|
// predicate: объект (частичное совпадение по ===) или функция row => bool
|
||||||
@@ -609,7 +611,7 @@ await step('Менеджер утверждает', async () => {
|
|||||||
await step('Кладовщик проверяет статус', async () => {
|
await step('Кладовщик проверяет статус', async () => {
|
||||||
// страница кладовщика ТА ЖЕ — форма открыта, навигация не нужна
|
// страница кладовщика ТА ЖЕ — форма открыта, навигация не нужна
|
||||||
const state = await clerk.getFormState();
|
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 }) {
|
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
||||||
await fillFields({ [field]: value });
|
await fillFields({ [field]: value });
|
||||||
const state = await getFormState();
|
const state = await getFormState();
|
||||||
assert.equal(state.fields[field]?.value, String(value));
|
assert.equal(state.fields.find(f => f.name === field)?.value, String(value));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default async function({ navigateSection, openCommand, filterList, unfilt
|
|||||||
await filterList('ООО Север', { field: 'Наименование', exact: true });
|
await filterList('ООО Север', { field: 'Наименование', exact: true });
|
||||||
const t = await readTable({ maxRows: 50 });
|
const t = await readTable({ maxRows: 50 });
|
||||||
log(`exact 'ООО Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`);
|
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]['Наименование'], 'ООО Север', 'Это должно быть ООО Север');
|
assert.equal(t.rows[0]['Наименование'], 'ООО Север', 'Это должно быть ООО Север');
|
||||||
await unfilterList();
|
await unfilterList();
|
||||||
await closeForm();
|
await closeForm();
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export default async function({ navigateSection, openCommand, clickElement, clos
|
|||||||
assert.ok(s.tables?.length >= 1, 'На форме списка есть таблица');
|
assert.ok(s.tables?.length >= 1, 'На форме списка есть таблица');
|
||||||
assert.ok(s.tables[0].columns?.length >= 2, 'У таблицы есть колонки');
|
assert.ok(s.tables[0].columns?.length >= 2, 'У таблицы есть колонки');
|
||||||
assert.ok(s.buttons?.length >= 1, 'На форме есть кнопки');
|
assert.ok(s.buttons?.length >= 1, 'На форме есть кнопки');
|
||||||
|
// title читается из шапки формы (атрибут .toplineBoxTitle), а не из панели открытых окон —
|
||||||
|
// она отключаема в настройках 1С. Панель остаётся запасным источником.
|
||||||
|
assert.formTitle(s, 'Контрагенты', 'Заголовок формы списка');
|
||||||
|
assert.noErrors(s, 'На только что открытой форме списка ошибок быть не должно');
|
||||||
await closeForm();
|
await closeForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,6 +30,8 @@ export default async function({ navigateSection, openCommand, clickElement, clos
|
|||||||
assert.ok(s.fields?.length >= 1, 'На форме элемента есть поля');
|
assert.ok(s.fields?.length >= 1, 'На форме элемента есть поля');
|
||||||
const named = s.fields.find(f => f.name === 'Наименование');
|
const named = s.fields.find(f => f.name === 'Наименование');
|
||||||
log(`Наименование: label='${named?.label}' value='${named?.value}'`);
|
log(`Наименование: label='${named?.label}' value='${named?.value}'`);
|
||||||
|
assert.formHasField(s, 'Наименование', 'formHasField ищет по массиву fields, а не по ключу');
|
||||||
|
assert.formTitle(s, 'Контрагент', 'Заголовок формы элемента');
|
||||||
assert.ok(named, 'Должно быть поле Наименование');
|
assert.ok(named, 'Должно быть поле Наименование');
|
||||||
assert.equal(named.value, 'ООО Север', 'value поля Наименование');
|
assert.equal(named.value, 'ООО Север', 'value поля Наименование');
|
||||||
assert.ok(named.label, 'У поля есть label');
|
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}`);
|
log(`after F4: form=${s.form} formCount=${s.formCount} modal=${s.modal}`);
|
||||||
assert.equal(s.modal, true, 'state.modal=true для модальной формы выбора');
|
assert.equal(s.modal, true, 'state.modal=true для модальной формы выбора');
|
||||||
assert.ok(s.formCount >= 2, 'formCount >= 2 (родитель + модальная)');
|
assert.ok(s.formCount >= 2, 'formCount >= 2 (родитель + модальная)');
|
||||||
|
// Заголовок берётся у всплывающего окна, а не у родителя: видимы обе шапки, а панель
|
||||||
|
// открытых окон в этот момент показывает родителя ('Приходная накладная').
|
||||||
|
assert.formTitle(s, 'Выбор контрагента', 'title всплывающего окна, а не родительской формы');
|
||||||
|
|
||||||
await closeForm();
|
await closeForm();
|
||||||
await closeForm({ save: false });
|
await closeForm({ save: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user