feat(web-test): чтение и раскрытие сворачиваемых групп формы

getFormState().groups → [{name, title, collapsed}] для сворачиваемых
групп (оба варианта ControlRepresentation: заголовок-гиперссылка и
картинка-каретка #titleBtn). Обычные несворачиваемые группы не
попадают. Состояние — по display первого контент-сиблинга за #title_div
(переживает свободные элементы между группами: при обходе Form.xml дети
группы идут до следующего сиблинга).

clickElement(title, {expand}/{expand:false}/{toggle}) раскрывает/
сворачивает группу — единый словарь с грид-узлами/деревьями, клик по
#titleBtn (вариант «картинка») или заголовку-гиперссылке. Новый
kind:'formGroup' в findClickTargetScript + хендлер click-group.mjs.

Фикстура СтраницаНастроек расширена вариантами A/B + негатив (обычная
группа) + стресс-привязка (свободный элемент между группами); тест
25-decoration-form покрывает чтение и expand/collapse/toggle. Полный
регресс 29 passed.

Popup-группы: содержимое в отдельном слое, состояние пока не читается
надёжно (follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-18 12:56:55 +03:00
parent 86cddc8ec3
commit bd86ece90e
7 changed files with 167 additions and 5 deletions
+7
View File
@@ -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 }`.
@@ -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.
// Идентификация раскрываемой группы: у неё есть заголовок <base>#title_text, и либо этот
// заголовок — гиперссылка (.staticTextHyper: ControlRepresentation=TitleHyperlink / popup),
// либо рядом есть кнопка-каретка <base>#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) {
+26 -2
View File
@@ -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' });
});
// Сворачиваемые/всплывающие группы — заголовок как цель раскрытия/сворачивания.
// Идентификация: <base>#title_text — гиперссылка (TitleHyperlink/popup) ЛИБО рядом есть
// кнопка-каретка <base>#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;
}
@@ -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);
@@ -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.',
});
}
@@ -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' },
+38 -1
View File
@@ -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();
});
}