diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 42fd6266..3310012e 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -146,6 +146,13 @@ 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 form groups: `[{ name, title, collapsed }]`. `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 "Администрирование → Интернет-поддержка и сервисы"). Expand/collapse a group by its title with `clickElement`, same vocabulary as tree nodes: `{ expand: true }` opens (idempotent), `{ expand: false }` collapses, `{ toggle: true }` flips. Plain (non-collapsible) groups are not listed. +```js +const form = await getFormState(); +// form.groups = [{ name: "ГруппаНовости", title: "Новости", collapsed: true }, ...] +await clickElement('Новости', { expand: true }); // reveal the group's content +``` + **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. **table** — backward-compatible alias for the first grid: `{ present, columns, rowCount }`. diff --git a/.claude/skills/web-test/scripts/dom/_shared.mjs b/.claude/skills/web-test/scripts/dom/_shared.mjs index 7a50168b..730049e5 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.5 — embedded JS function constants +// web-test dom shared v1.6 — embedded JS function constants // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Shared function strings embedded into page.evaluate() generators. @@ -674,12 +674,37 @@ function readForm(p) { }); if (iframeCount) result.iframes = iframeCount; + // Collapsible / popup groups — surface that part of the form is hidden + its state. + // Идентификация раскрываемой группы: у неё есть заголовок #title_text, и либо этот + // заголовок — гиперссылка (.staticTextHyper: ControlRepresentation=TitleHyperlink / popup), + // либо рядом есть кнопка-каретка #titleBtn (ControlRepresentation=Picture). Обычные + // (несворачиваемые) группы не имеют ни того, ни другого — их не показываем. + // Состояние: DOM у 1С плоский (контент группы — сиблинги под mainGroup, не вложены), но при + // глубинном обходе Form.xml ПЕРВЫЙ контент-сиблинг сразу за #title_div — всегда дочерний + // элемент группы (свободные соседи идут после всех детей). Его display = состояние группы: + // none → свёрнута, block → развёрнута. Переживает свободные элементы между группами. + const groups = []; + document.querySelectorAll('[id^="' + p + '"][id$="#title_text"]').forEach(tt => { + if (tt.offsetWidth === 0 && tt.offsetHeight === 0) return; + const base = tt.id.slice(0, -('#title_text'.length)); + const isHyper = tt.classList.contains('staticTextHyper'); + const hasBtn = !!document.getElementById(base + '#titleBtn'); + if (!isHyper && !hasBtn) return; // обычная (несворачиваемая) группа — пропускаем + const title = nbsp(tt.innerText?.trim() || ''); + const g = { name: base.replace(p, ''), title }; + const titleDiv = document.getElementById(base + '#title_div'); + const contentSib = titleDiv?.nextElementSibling; + if (contentSib) g.collapsed = getComputedStyle(contentSib).display === 'none'; + groups.push(g); + }); + if (fields.length) result.fields = fields; if (buttons.length) result.buttons = buttons; if (formTabs.length) result.tabs = formTabs; if (navigation.length) result.navigation = navigation; if (texts.length) result.texts = texts; if (hyperlinks.length) result.hyperlinks = hyperlinks; + if (groups.length) result.groups = groups; // Group DCS report settings into readable format if (result.fields) { diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs index 18a75863..de2fb446 100644 --- a/.claude/skills/web-test/scripts/dom/forms.mjs +++ b/.claude/skills/web-test/scripts/dom/forms.mjs @@ -1,4 +1,4 @@ -// web-test dom/forms v1.10 — form detection, content read, click-target/field-button resolution +// web-test dom/forms v1.11 — form detection, content read, click-target/field-button resolution // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { DETECT_FORM_FN, READ_FORM_FN, ROW_CLICK_POINT_FN } from './_shared.mjs'; @@ -95,13 +95,36 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector } items.push(item); }); - // Hyperlinks (staticTextHyper) + // Hyperlinks (staticTextHyper) — кроме заголовков сворачиваемых групп (#title_text), + // они обрабатываются ниже как kind:'formGroup' (клик = раскрыть/свернуть, не переход). [...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => { + if (el.id.endsWith('#title_text')) return; const idName = el.id.replace(p, ''); const text = norm(el.innerText); items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' }); }); + // Сворачиваемые/всплывающие группы — заголовок как цель раскрытия/сворачивания. + // Идентификация: #title_text — гиперссылка (TitleHyperlink/popup) ЛИБО рядом есть + // кнопка-каретка #titleBtn (ControlRepresentation=Picture). Обычные группы пропускаем. + // Мишень клика: #titleBtn (вариант «картинка») иначе сам заголовок-гиперссылка. + // Состояние: первый контент-сиблинг за #title_div (display:none = свёрнута). + [...document.querySelectorAll('[id^="' + p + '"][id$="#title_text"]')] + .filter(el => el.offsetWidth > 0 || el.offsetHeight > 0).forEach(el => { + const base = el.id.slice(0, -('#title_text'.length)); + const btn = document.getElementById(base + '#titleBtn'); + const btnVisible = btn && (btn.offsetWidth > 0 || btn.offsetHeight > 0); + if (!el.classList.contains('staticTextHyper') && !btnVisible) return; // обычная группа + const tgt = btnVisible ? btn : el; + const r = tgt.getBoundingClientRect(); + const titleDiv = document.getElementById(base + '#title_div'); + const sib = titleDiv && titleDiv.nextElementSibling; + const item = { id: '', kind: 'formGroup', name: norm(el.innerText) || base.replace(p, ''), + label: base.replace(p, ''), x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + if (sib) item.collapsed = getComputedStyle(sib).display === 'none'; + items.push(item); + }); + // Frame buttons [...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => { const text = norm(el.innerText); @@ -211,6 +234,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector } if (found) { const res = { id: found.id, kind: found.kind, name: found.name }; if (found.disabled) res.disabled = true; + if (found.collapsed != null) res.collapsed = found.collapsed; if (found.x != null) { res.x = found.x; res.y = found.y; } return res; } diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index 48adfcab..5fed2eb7 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -1,4 +1,4 @@ -// web-test core/click v1.23 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element / field-focus handlers by target kind. +// web-test core/click v1.24 — clickElement dispatcher: routes to spreadsheet / popup / form-group / grid-row / form-element / field-focus handlers by target kind. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected, highlightMode } from './state.mjs'; @@ -18,6 +18,7 @@ import { clickConfirmationButton, tryClickPopupItem, } from '../forms/click-popup.mjs'; import { clickFormTarget, focusFormField } from '../forms/click-form.mjs'; +import { clickFormGroupTarget } from '../forms/click-group.mjs'; import { clickSpreadsheetCell, findSpreadsheetCellByText, } from '../spreadsheet/spreadsheet.mjs'; @@ -123,6 +124,7 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi // 5. Dispatch to the right handler by target kind. const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector }; + if (target.kind === 'formGroup') return await clickFormGroupTarget(target, ctx); if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx); if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx); if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx); diff --git a/.claude/skills/web-test/scripts/engine/forms/click-group.mjs b/.claude/skills/web-test/scripts/engine/forms/click-group.mjs new file mode 100644 index 00000000..4b60cc4c --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/forms/click-group.mjs @@ -0,0 +1,29 @@ +// web-test forms/click-group v1.0 — 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('<заголовок группы>') — переключить (голый клик) +// +// target.collapsed приходит из findClickTargetScript (по display первого контент-сиблинга). + +import { waitForStable } from '../core/wait.mjs'; +import { modifierClick, returnFormState } from '../core/helpers.mjs'; +import { shouldClickToggle } from '../table/grid-toggle.mjs'; + +export async function clickFormGroupTarget(target, ctx) { + const { formNum, modifier, toggle, expand } = ctx; + // shouldClickToggle ждёт { isExpanded }; при неизвестном состоянии (undefined) кликаем всегда. + const state = target.collapsed == null ? null : { isExpanded: !target.collapsed }; + const shouldClick = shouldClickToggle(state, expand, toggle); + if (shouldClick) await modifierClick(target.x, target.y, modifier); + await waitForStable(formNum); + return returnFormState({ + clicked: { kind: 'formGroup', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, + hint: shouldClick + ? 'Group toggled. Call getFormState — its groups[].collapsed and revealed/hidden content update.' + : 'Group already in desired state.', + }); +} diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index eec944e7..bacf725a 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -1296,6 +1296,44 @@ export const steps = [ { label: 'ПояснениеКлассификаторы', title: 'Загрузка курсов валют и классификаторов' }, ], }, + // Развёрнутая сворачиваемая (collapsed:false) — сверка сигнала collapsed/expanded. + { group: 'collapsible', name: 'ГруппаРазвёрнутая', title: 'Развёрнутая группа', + collapsed: false, showTitle: true, children: [ + { label: 'ПояснениеРазвёрнутая', title: 'Содержимое развёрнутой группы' }, + ], + }, + // Вариант B: контрол сворачивания как КАРТИНКА (#titleBtn-каретка) — как в реальном ERP, + // в отличие от дефолтного «заголовок-гиперссылка» (вариант A) на этой платформе. + { group: 'collapsible', name: 'ГруппаКартинкой', title: 'Свёрнута картинкой', + controlRepresentation: 'Picture', collapsed: true, showTitle: true, children: [ + { label: 'ПояснениеКартинкой', title: 'Содержимое группы с картинкой-контролом' }, + ], + }, + // Всплывающая (popup) группа — тоже прячет содержимое за триггером. + { group: 'vertical', name: 'ГруппаВсплывающая', title: 'Всплывающая группа', behavior: 'popup', + collapsed: true, showTitle: true, children: [ + { label: 'ПояснениеВсплывающая', title: 'Содержимое всплывающей группы' }, + ], + }, + // Обычная НЕсворачиваемая группа — негатив: НЕ должна попадать в groups[] (не шуметь). + { group: 'vertical', name: 'ГруппаОбычная', title: 'Обычная группа', showTitle: true, children: [ + { label: 'ПояснениеОбычная', title: 'Содержимое обычной группы' }, + ], + }, + // СТРЕСС привязки контента: свёрнутая группа → свободный элемент верхнего уровня → + // развёрнутая группа. Проверяем, отличим ли «свободный сосед после свёрнутой» от + // «видимого дочернего развёрнутой» (порядок DOM их путает; нужен геометрич./иной сигнал). + { group: 'collapsible', name: 'ГруппаСтрессСвёрнутая', title: 'Стресс: свёрнутая', + collapsed: true, showTitle: true, children: [ + { label: 'ВнутриСтрессСвёрнутой', title: 'Внутри свёрнутой стресс-группы' }, + ], + }, + { label: 'СвободныйМеждуГруппами', title: 'Свободный элемент между группами' }, + { group: 'collapsible', name: 'ГруппаСтрессРазвёрнутая', title: 'Стресс: развёрнутая', + collapsed: false, 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 bee2e4f2..746a9677 100644 --- a/tests/web-test/25-decoration-form.test.mjs +++ b/tests/web-test/25-decoration-form.test.mjs @@ -8,8 +8,16 @@ export const timeout = 90000; // Регресс на расширенный селектор детекции формы (dom/_shared.mjs, DETECT_FORM_FN / // DETECT_FORMS_FN): до фикса detectForm возвращал null → getFormState = «No form detected» // (form:null, formCount:0), навык такую форму НЕ видел. +// +// Плюс: сворачиваемые группы формы в getFormState().groups (состояние collapsed) и их +// раскрытие/сворачивание через clickElement {expand}/{toggle}. Форма содержит оба варианта +// рендера контрола (заголовок-гиперссылка и картинка-каретка ControlRepresentation), негатив +// (обычная несворачиваемая группа — НЕ в groups[]) и стресс-привязку (свободный элемент между +// группами не путает определение состояния). -export default async function({ navigateSection, openCommand, navigateLink, getFormState, closeForm, assert, step, log }) { +export default async function({ navigateSection, openCommand, navigateLink, getFormState, clickElement, closeForm, assert, step, log }) { + + const collapsedOf = (s, name) => (s.groups || []).find(x => x.name === name)?.collapsed; await step('раздел: открыть «Страница настроек» командой из «Администрирование»', async () => { await navigateSection('Администрирование'); @@ -33,4 +41,33 @@ export default async function({ navigateSection, openCommand, navigateLink, getF assert.includes(hy, 'Техническая информация', 'гиперссылка видна и в getFormState'); await closeForm(); }); + + await step('groups: getFormState показывает сворачиваемые группы + состояние', async () => { + const s = await navigateLink('Обработка.СтраницаНастроек'); + log(`groups=${JSON.stringify((s.groups || []).map(g => [g.name, g.collapsed]))}`); + assert.ok(s.groups?.length, 'groups[] присутствует'); + assert.equal(collapsedOf(s, 'ГруппаКлассификаторы'), true, 'вариант A (гиперссылка) — свёрнута'); + assert.equal(collapsedOf(s, 'ГруппаРазвёрнутая'), false, 'развёрнутая — collapsed:false'); + assert.equal(collapsedOf(s, 'ГруппаКартинкой'), true, 'вариант B (картинка) — свёрнута'); + assert.ok(!s.groups.some(g => g.name === 'ГруппаОбычная'), + 'обычная несворачиваемая группа НЕ попадает в groups[] (негатив — не шумит)'); + // Стресс-привязка: свободный элемент между группами не путает определение состояния. + assert.equal(collapsedOf(s, 'ГруппаСтрессСвёрнутая'), true, 'стресс: свёрнутая определена верно'); + assert.equal(collapsedOf(s, 'ГруппаСтрессРазвёрнутая'), false, 'стресс: развёрнутая определена верно'); + }); + + await step('group toggle: clickElement {expand}/{toggle} по вариантам A и B', async () => { + // Вариант A (заголовок-гиперссылка): раскрыть → идемпотентный повтор → свернуть. + let r = await clickElement('Классификаторы и курсы валют', { expand: true }); + assert.equal(r.clicked.toggled, true, 'A expand:true — кликнул'); + assert.equal(collapsedOf(r, 'ГруппаКлассификаторы'), false, 'A раскрыта'); + r = await clickElement('Классификаторы и курсы валют', { expand: true }); + assert.equal(r.clicked.toggled, false, 'A expand:true повторно — идемпотентно (no-op)'); + r = await clickElement('Классификаторы и курсы валют', { expand: false }); + assert.equal(collapsedOf(r, 'ГруппаКлассификаторы'), true, 'A свёрнута обратно'); + // Вариант B (картинка-каретка #titleBtn): toggle. + r = await clickElement('Свёрнута картинкой', { toggle: true }); + assert.equal(collapsedOf(r, 'ГруппаКартинкой'), false, 'B раскрыта тоглом'); + await closeForm(); + }); }