diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index cc3a123d..d42389a6 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -149,10 +149,17 @@ Returns current form structure. This is the primary way to understand what's on **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. "Основное", "Объекты метаданных", "Подсистемы"). **groups** — collapsible and pop-up form groups: `[{ name, title, collapsed, behavior? }]`. `collapsed: true` means the group's content is hidden — part of the form is not shown until you expand it (common on settings pages like "Администрирование → Интернет-поддержка и сервисы"). `behavior: 'popup'` marks a pop-up group (content shows in a floating panel); absent for ordinary collapsible groups. Expand/collapse (or open/close a pop-up) by the group title with `clickElement`, same vocabulary as tree nodes: `{ expand: true }` reveals (idempotent), `{ expand: false }` hides, `{ toggle: true }` flips. After expanding, the group's content becomes readable in the next `getFormState()` (its fields/hyperlinks/texts appear). Plain (non-collapsible) groups are not listed. +A group's title is not a stable key: the same caption repeats across blocks of a form, and a +group may swap it when expanded ("Показать детализацию" ↔ "Скрыть детализацию"), after which a +click by the old caption fails with "not found". The click result therefore reports `group` (the +group's technical name, which never changes and is accepted by `clickElement`) and `title` (its +caption right now) — use `group` when you plan to click the same group again. ```js const form = await getFormState(); // form.groups = [{ name: "ГруппаНовости", title: "Новости", collapsed: true }, ...] -await clickElement('Новости', { expand: true }); // reveal the group's content +const r = await clickElement('Новости', { expand: true }); // reveal the group's content +// r.clicked = { kind: 'formGroup', name: 'Новости', group: 'ГруппаНовости', title: 'Новости', toggled: true } +await clickElement(r.clicked.group, { expand: false }); // stable key — safe to reuse ``` **tables** — array of all visible grids: `[{ name, columns, rowCount, label? }]`. `label` is the visual group title shown on screen (e.g. "Входящие"), absent when grid has no visible title. Use `readTable()` for actual data. diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 03da0dec..c5d98ffc 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -14,6 +14,7 @@ export { closeCrossScript, readFormScript, findClickTargetScript, + scrollGroupIntoViewScript, findFieldButtonScript, resolveFieldsScript, detectNewFormScript, diff --git a/.claude/skills/web-test/scripts/dom/_shared.mjs b/.claude/skills/web-test/scripts/dom/_shared.mjs index 67635c56..e8d2232a 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.9 — embedded JS function constants +// web-test dom shared v1.10 — embedded JS function constants // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Shared function strings embedded into page.evaluate() generators. @@ -69,24 +69,39 @@ export const TEXT_CLICK_POINT_FN = `function textClickPoint(el) { * Collapsed state of a form group — single source of truth for getFormState().groups[] * and the click-target resolver. * - * PopUp groups: the panel `#panel_div` carries the state directly. + * 1C lays the form out FLAT: a group's content is not nested inside it but follows as + * absolutely-positioned siblings of `#title_div`. Anything derived from "the first + * sibling" is unreliable — measured on live forms: + * • before a container child comes an empty `.logicGroupContainer` (height 0), spelled + * `#group_div` for a table but `_div` for a nested group; + * • that wrapper's own display FLIPS between runs (block before the first toggle, none + * after) — this is the "readings synced after the first toggle" from the bug report; + * • a group's leading nodes can stay `display:none` by their own logic (a table whose + * command bar is hidden) while the visible content sits further down the chain. + * Neither the wrappers' geometry nor the group's own `_div` can serve as the signal: + * all of them are always zero-height. * - * Collapsible groups: 1C lays the form out FLAT (content is not nested in the group, it - * follows as siblings of `#title_div`, absolutely positioned). Usually the first - * sibling is the group's own first child, and its `display` IS the state. - * - * But when that child is itself a container (a table, a nested group), 1C inserts a - * bookkeeping wrapper `#group_div.logicGroupContainer` FIRST — always - * `display:block`, always `height:0`, and EMPTY: the real nodes - * (`КоманднаяПанель_div`, `_div`, …) follow it as further siblings, - * they are not inside it. Reading that wrapper's display reported `collapsed:false` for a - * group that was genuinely collapsed, so `{expand:true}` became a silent no-op. - * - * So: on hitting the wrapper, walk the following siblings while they share its id prefix - * (the prefix stops the walk at foreign nodes such as `separatePanelArea`, keeping the - * "free element between groups" case safe) and report collapsed when none of them is - * visible. Neither the wrapper's own geometry nor the group's `_div` can serve as - * the signal — both are always zero-height. + * Signals, in order: + * 1. PopUp — the panel `#panel_div` carries the state directly. + * 2. Caret (`ControlRepresentation=Picture`) — `#titleBtn img` is the `hideshow` + * sprite, frame `gx`: 0 collapsed, non-zero expanded. Note the polarity is OPPOSITE + * to tree nodes in dom/grid.mjs (gx=0 = expanded there) — different sprite. + * 3. Otherwise (`TitleHyperlink`, which has no caret, no aria-expanded and no state class + * on the title): ownership by INDENT. A group's children sit deeper than its title + * (`#title_div` at left:12px → children at 22px), while a free element between groups + * sits at the title's own level. So walk the siblings, skip hidden nodes and wrappers + * (they are not positioned — left comes back `auto`), and the first VISIBLE node + * decides: deeper than the title ⇒ own content ⇒ expanded; same level or shallower + * ⇒ that's already someone else, stop. Nothing own and visible ⇒ collapsed, which is + * sound because a group with every element hidden is not rendered by the platform at all. + * The baseline is the leftmost part of the title BLOCK, not `#title_div` alone: with a + * caret the text is pushed right by its width (measured live: caret box 12px, title + * 33px, own children 22px), so anchoring on the title alone would read the group's own + * child as foreign. Only matters if a caret is present but signal 2 did not fire. + * The walk is capped: a group's own nodes sit right after its title, whereas the LAST + * collapsed group on a form has no boundary behind it at all — measured live, the first + * node with height came 107 siblings later, deep inside an unrelated branch, and would + * have been mistaken for the group's content. * * @param base element id prefix without suffix, e.g. `form1_ГруппаТовары` * @returns `true` collapsed, `false` expanded, `null` when the layout is unrecognised. @@ -94,21 +109,28 @@ export const TEXT_CLICK_POINT_FN = `function textClickPoint(el) { export const GROUP_STATE_FN = `function groupCollapsed(base) { const panelDiv = document.getElementById(base + '#panel_div'); if (panelDiv) return getComputedStyle(panelDiv).display === 'none'; - const titleDiv = document.getElementById(base + '#title_div'); - const sib = titleDiv && titleDiv.nextElementSibling; - if (!sib) return null; - const WRAP = '#group_div'; - if (sib.id && sib.id.endsWith(WRAP) && sib.classList.contains('logicGroupContainer')) { - const pfx = sib.id.slice(0, -WRAP.length); - let seen = false; - for (let n = sib.nextElementSibling; n; n = n.nextElementSibling) { - if (!n.id || !n.id.startsWith(pfx)) break; - seen = true; - if (n.offsetWidth > 0 && n.offsetHeight > 0) return false; - } - return seen ? true : null; + const caret = document.querySelector('[id="' + base + '#titleBtn"] img'); + const src = caret ? (caret.getAttribute('src') || '') : ''; + if (src.indexOf('hideshow') !== -1) { + const gx = src.match(/[?&]gx=(\\d+)/); + if (gx) return gx[1] === '0'; } - return getComputedStyle(sib).display === 'none'; + const titleDiv = document.getElementById(base + '#title_div'); + if (!titleDiv) return null; + let titleLeft = parseFloat(getComputedStyle(titleDiv).left); + const caretDiv = document.getElementById(base + '#titleBtn_div'); + const caretLeft = caretDiv ? parseFloat(getComputedStyle(caretDiv).left) : NaN; + if (!isNaN(caretLeft) && (isNaN(titleLeft) || caretLeft < titleLeft)) titleLeft = caretLeft; + if (isNaN(titleLeft)) return null; + let candidates = false, scanned = 0; + for (let n = titleDiv.nextElementSibling; n && scanned < 20; n = n.nextElementSibling, scanned++) { + if (n.offsetWidth === 0 && n.offsetHeight === 0) { candidates = true; continue; } + const left = parseFloat(getComputedStyle(n).left); + if (isNaN(left)) { candidates = true; continue; } + if (left > titleLeft) return false; + break; + } + return candidates ? true : null; }`; /** diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs index a0a3683e..be4929de 100644 --- a/.claude/skills/web-test/scripts/dom/forms.mjs +++ b/.claude/skills/web-test/scripts/dom/forms.mjs @@ -324,6 +324,39 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector } })()`; } +/** + * Scroll a collapsible group's title into view and return the FRESH click point for it. + * + * A group's title is clicked by coordinates (its click target is a nested label, not the + * element with the id), and `mouse.click` outside the viewport lands nowhere — the toggle + * silently did nothing. Seen live: the second «Показать детализацию» on a long form sat at + * y=848 with a viewport of 834. Playwright's own auto-scroll does not apply here because + * that path is selector-based, not coordinate-based. + * + * Same target choice as findClickTargetScript: the caret `#titleBtn` when visible (compact, + * aim at its centre), otherwise the title text via textClickPoint. + * + * @returns `{ x, y }` after scrolling, or `null` when the group is not on the form. + */ +export function scrollGroupIntoViewScript(formNum, groupName) { + const p = `form${formNum}_`; + return `(() => { + ${TEXT_CLICK_POINT_FN} + const base = ${JSON.stringify(p)} + ${JSON.stringify(groupName)}; + const tt = document.getElementById(base + '#title_text'); + const btn = document.getElementById(base + '#titleBtn'); + const btnVisible = btn && (btn.offsetWidth > 0 || btn.offsetHeight > 0); + const anchor = btnVisible ? btn : tt; + if (!anchor) return null; + anchor.scrollIntoView({ block: 'center' }); + if (btnVisible) { + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + return textClickPoint(tt); + })()`; +} + /** * Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name. * Returns { fieldName, buttonId, buttonType } or { error, available }. diff --git a/.claude/skills/web-test/scripts/engine/forms/click-group.mjs b/.claude/skills/web-test/scripts/engine/forms/click-group.mjs index 30b8b757..14bea802 100644 --- a/.claude/skills/web-test/scripts/engine/forms/click-group.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/click-group.mjs @@ -1,24 +1,38 @@ -// web-test forms/click-group v1.1 — click handler for collapsible/popup form-group titles. +// web-test forms/click-group v1.2 — click handler for collapsible/popup form-group titles. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // // Reuses the tree/grid expand vocabulary so the model has ONE mental model: -// clickElement('<заголовок группы>', { expand: true }) — раскрыть (идемпотентно) -// clickElement('<заголовок группы>', { expand: false }) — свернуть (идемпотентно) -// clickElement('<заголовок группы>', { toggle: true }) — переключить -// clickElement('<заголовок группы>') — переключить (голый клик) +// clickElement('', { expand: true }) — reveal (idempotent) +// clickElement('', { expand: false }) — hide (idempotent) +// clickElement('', { toggle: true }) — flip +// clickElement('') — flip (bare click) // -// target.collapsed приходит из findClickTargetScript (по display первого контент-сиблинга). +// target.collapsed comes from findClickTargetScript (groupCollapsed in dom/_shared.mjs). +import { page } from '../core/state.mjs'; +import { scrollGroupIntoViewScript } from '../../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; import { modifierClick, returnFormState } from '../core/helpers.mjs'; import { shouldClickToggle } from '../table/grid-toggle.mjs'; +// Group captions repeat across a form's blocks («Показать детализацию» in every block), and a +// collapsible group swaps its own caption when expanded (CollapsedRepresentationTitle: +// «Показать детализацию» ↔ «Скрыть детализацию»). Compare normalised so a real caption swap +// is not confused with nbsp/ё spelling differences. +const norm = (s) => (s || '').replace(/ /g, ' ').replace(/ё/gi, 'е').trim().toLowerCase(); + export async function clickFormGroupTarget(target, ctx) { const { formNum, modifier, toggle, expand } = ctx; - // shouldClickToggle ждёт { isExpanded }; при неизвестном состоянии (undefined) кликаем всегда. + // shouldClickToggle expects { isExpanded }; with an unknown state (undefined) always click. const state = target.collapsed == null ? null : { isExpanded: !target.collapsed }; const shouldClick = shouldClickToggle(state, expand, toggle); - if (shouldClick) await modifierClick(target.x, target.y, modifier); + if (shouldClick) { + // The target is clicked by coordinates, and on a long form those go stale or fall outside + // the viewport — the click then silently does nothing. Scroll the title into view and take + // a fresh point. + const pt = await page.evaluate(scrollGroupIntoViewScript(formNum, target.label)); + await modifierClick(pt?.x ?? target.x, pt?.y ?? target.y, modifier); + } await waitForStable(formNum); const result = await returnFormState({ clicked: { kind: 'formGroup', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, @@ -27,11 +41,22 @@ export async function clickFormGroupTarget(target, ctx) { : 'Group already in desired state.', }); - // Постусловие: клик, который ничего не переключил, — молчаливая ложь (то же правило, что - // guard на disabled в core/click.mjs). Проверяем ТОЛЬКО когда клик реально был сделан и - // состояние читаемо: при collapsed == null (нераспознанная вёрстка) сверять не с чем. + // The technical name is the stable key: it finds the entry in groups[] and clicks the same + // group again. The caption cannot do that — it changes when the group expands. + const after = (result.groups || []).find(g => g.name === target.label); + if (target.label) result.clicked.group = target.label; + if (after) { + result.clicked.title = after.title; + if (norm(after.title) !== norm(target.name)) { + result.hint += ` The group is now titled "${after.title}"` + + ` — click it by that caption or by its technical name "${target.label}".`; + } + } + + // Postcondition: a click that toggled nothing is a silent lie (same rule as the disabled + // guard in core/click.mjs). Checked ONLY when a click actually happened and the state is + // readable: with collapsed == null (unrecognised layout) there is nothing to compare against. if (shouldClick && target.collapsed != null) { - const after = (result.groups || []).find(g => g.name === target.label); if (after && after.collapsed === target.collapsed) { throw new Error(`clickElement: group "${target.name}" did not toggle ` + `(collapsed stayed ${after.collapsed})`); diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index e8ccdd97..3f27e767 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -1372,6 +1372,47 @@ export const steps = [ ]}, ], }, + // ВЛОЖЕННАЯ ГРУППА ПЕРВЫМ РЕБЁНКОМ. Служебную обёртку .logicGroupContainer платформа + // пишет двумя способами: у таблицы — <дочерний>#group_div (группа выше), у вложенной + // группы — <дочерний>_div. Правило, узнающее только первую форму, на этой группе + // читает display самой обёртки (block) и рапортует «раскрыта» у свёрнутой. + { group: 'collapsible', name: 'ГруппаСВложенной', title: 'Свёрнутая с вложенной группой', + collapsed: true, showTitle: true, children: [ + { group: 'vertical', name: 'ВложеннаяОбычная', title: 'Вложенная группа', showTitle: true, + children: [ + { label: 'ВнутриВложенной', title: 'Содержимое вложенной группы' }, + ], + }, + ], + }, + // ПЕРВЫЙ УЗЕЛ СКРЫТ СВОЕЙ ЛОГИКОЙ, видимое содержимое идёт дальше по цепочке сиблингов + // (на боевой форме так ведёт себя таблица со скрытой командной панелью). Правило + // «display первого контент-узла» здесь врёт в обе стороны: у раскрытой группы читает + // «свёрнута», из-за чего clickElement({expand}) считает клик несработавшим. + // Два варианта контрола: заголовок-гиперссылка и картинка-каретка. + { group: 'collapsible', name: 'ГруппаСкрытыйПервый', title: 'Скрыт первый узел', + collapsed: true, showTitle: true, children: [ + { label: 'СкрытыйПервыйУзел', title: 'Эта декорация скрыта', visible: false }, + { label: 'ВидимыйВторойУзел', title: 'Видно только эту декорацию' }, + ], + }, + { group: 'collapsible', name: 'ГруппаСкрытыйПервыйКартинка', title: 'Скрыт первый узел (картинка)', + controlRepresentation: 'Picture', collapsed: true, showTitle: true, children: [ + { label: 'СкрытыйПервыйУзелК', title: 'Эта декорация скрыта (картинка)', visible: false }, + { label: 'ВидимыйВторойУзелК', title: 'Видно только эту декорацию (картинка)' }, + ], + }, + // МЕНЯЮЩИЙСЯ ЗАГОЛОВОК (CollapsedRepresentationTitle): свёрнута — «Показать подробности», + // раскрыта — «Скрыть подробности». Повторный клик по прежнему тексту уже не найдёт + // элемент, поэтому ответ на клик отдаёт техническое имя группы и её текущий заголовок. + // Стоит ПОСЛЕДНЕЙ на форме: с раскрытой группой-с-таблицей уезжает ниже вьюпорта, так что + // заодно покрывает клик по цели за пределами видимой области (координатный клик туда + // не доходил — молчаливый no-op). + { group: 'collapsible', name: 'ГруппаМеняющийсяЗаголовок', title: 'Скрыть подробности', + collapsedTitle: 'Показать подробности', collapsed: true, showTitle: true, children: [ + { label: 'ВнутриПодробностей', title: 'Содержимое подробностей' }, + ], + }, ], }, args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/DataProcessors/СтраницаНастроек/Forms/ФормаОбработки/Ext/Form.xml' }, diff --git a/tests/web-test/25-decoration-form.test.mjs b/tests/web-test/25-decoration-form.test.mjs index 66a5eae1..a1709c97 100644 --- a/tests/web-test/25-decoration-form.test.mjs +++ b/tests/web-test/25-decoration-form.test.mjs @@ -101,6 +101,89 @@ export default async function({ navigateSection, openCommand, navigateLink, getF await closeForm(); }); + await step('вложенная группа первым ребёнком: обёртка вида <дочерний>_div', async () => { + // Служебную обёртку .logicGroupContainer платформа пишет двумя способами: у таблицы — + // <дочерний>#group_div, у вложенной группы — <дочерний>_div. Правило, знающее только + // первую форму, читает display обёртки (block) и рапортует «раскрыта» у свёрнутой. + const виден = (st) => (st.texts || []).some(t => /Содержимое вложенной группы/.test(t.value)); + const s = await navigateLink('Обработка.СтраницаНастроек'); + assert.equal(collapsedOf(s, 'ГруппаСВложенной'), true, 'свёрнута сразу после открытия'); + assert.equal(виден(s), false, 'содержимое вложенной группы не видно'); + + let r = await clickElement('Свёрнутая с вложенной группой', { expand: true }); + assert.equal(collapsedOf(r, 'ГруппаСВложенной'), false, 'раскрыта'); + assert.equal(виден(r), true, 'содержимое вложенной группы появилось'); + + r = await clickElement('Свёрнутая с вложенной группой', { expand: true }); + assert.equal(r.clicked.toggled, false, 'expand:true повторно — идемпотентно'); + + r = await clickElement('Свёрнутая с вложенной группой', { expand: false }); + assert.equal(collapsedOf(r, 'ГруппаСВложенной'), true, 'свёрнута обратно'); + assert.equal(виден(r), false, 'содержимое скрылось'); + await closeForm(); + }); + + await step('скрытый первый узел: состояние берётся не по первому сиблингу', async () => { + // Первый элемент группы скрыт своей логикой (visible:false), видимое содержимое — дальше + // по цепочке сиблингов. Так ведёт себя боевая форма с таблицей, у которой скрыта командная + // панель: чтение «display первого контент-узла» у РАСКРЫТОЙ группы даёт «свёрнута», и + // clickElement({expand}) считает свой клик несработавшим. + const пары = [ + ['Скрыт первый узел', 'ГруппаСкрытыйПервый', /^Видно только эту декорацию$/], + ['Скрыт первый узел (картинка)', 'ГруппаСкрытыйПервыйКартинка', /Видно только эту декорацию \(картинка\)/], + ]; + for (const [заголовок, имя, маркер] of пары) { + const s = await navigateLink('Обработка.СтраницаНастроек'); + const виден = (st) => (st.texts || []).some(t => маркер.test(t.value)); + assert.equal(collapsedOf(s, имя), true, `${имя}: свёрнута после открытия`); + assert.equal(виден(s), false, `${имя}: содержимое скрыто`); + + let r = await clickElement(заголовок, { expand: true }); + assert.equal(collapsedOf(r, имя), false, `${имя}: раскрыта (первый узел так и остался скрытым)`); + assert.equal(виден(r), true, `${имя}: видимый узел появился`); + + r = await clickElement(заголовок, { expand: true }); + assert.equal(r.clicked.toggled, false, `${имя}: expand:true повторно — идемпотентно`); + + r = await clickElement(заголовок, { expand: false }); + assert.equal(collapsedOf(r, имя), true, `${имя}: свёрнута обратно`); + assert.equal(виден(r), false, `${имя}: содержимое скрылось`); + await closeForm(); + } + }); + + await step('меняющийся заголовок + цель ниже вьюпорта: ответ даёт стабильный ключ', async () => { + // У группы задан CollapsedRepresentationTitle, поэтому текст заголовка меняется при + // раскрытии, и повторный клик по прежнему тексту падает с «not found». Ответ на клик + // отдаёт техническое имя (group) и текущий заголовок (title), а hint предупреждает о смене. + // Группа последняя на форме: раскрытая выше таблица уводит её за пределы окна — координатный + // клик туда раньше не доходил, теперь цель сперва скроллится в вид. + const G = 'ГруппаМеняющийсяЗаголовок'; + const s = await navigateLink('Обработка.СтраницаНастроек'); + assert.equal(collapsedOf(s, G), true, 'свёрнута после открытия'); + assert.equal((s.groups || []).find(g => g.name === G)?.title, 'Показать подробности', + 'в свёрнутом виде — заголовок свёрнутого представления'); + await clickElement('Свёрнутая с таблицей', { expand: true }); // удлиняем форму + + let r = await clickElement('Показать подробности', { expand: true }); + assert.equal(r.clicked.toggled, true, 'кликнул (цель за вьюпортом — со скроллом)'); + assert.equal(collapsedOf(r, G), false, 'раскрыта'); + assert.equal(r.clicked.group, G, 'clicked.group — техническое имя группы'); + assert.equal(r.clicked.title, 'Скрыть подробности', 'clicked.title — текущий заголовок'); + assert.includes(r.hint, 'now titled "Скрыть подробности"', 'hint предупреждает о смене заголовка'); + + // Прежний текст больше не находится — ради этого в ответе и есть стабильный ключ. + await assert.throws(() => clickElement('Показать подробности', { expand: false }), + 'клик по прежнему заголовку не проходит'); + + r = await clickElement(r.clicked.group, { expand: true }); + assert.equal(r.clicked.toggled, false, 'по техническому имени expand идемпотентен'); + r = await clickElement(G, { expand: false }); + assert.equal(collapsedOf(r, G), true, 'свёрнута обратно по техническому имени'); + assert.equal(r.clicked.title, 'Показать подробности', 'заголовок вернулся к свёрнутому'); + await closeForm(); + }); + await step('растянутая гиперссылка: клик доходит до обработчика', async () => { // Проверка соседнего с дефектом 2 случая. Ссылка растянута на всю ширину формы // (horizontalStretch + autoMaxWidth:false → контейнер 1295px), но промаха тут НЕ бывает: