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();
+ });
}