fix(web-test): состояние группы по контролу сворачивания, а не по вёрстке содержимого

На боевой форме вскрылись случаи, которые прежнее правило не покрывало. Замеры показали,
что любая эвристика «по первому сиблингу за заголовком» нежизнеспособна:

- служебная обёртка .logicGroupContainer пишется двумя способами (у таблицы
  <дочерний>#group_div, у вложенной группы <дочерний>_div) — знали только первый;
- display самой обёртки плавает: block до первого тогла, none после;
- первые узлы группы могут быть скрыты своей логикой, а видимое содержимое идёт дальше
  по цепочке — раскрытая группа читалась как свёрнутая, и постусловие роняло успешный клик.

Теперь состояние берётся из контрола сворачивания: у варианта «картинка» — кадр gx спрайта
hideshow у каретки (полярность обратна дереву в dom/grid.mjs), у варианта «гиперссылка»
каретки нет, там принадлежность по отступу — дети группы смещены глубже её заголовка,
свободный сосед стоит на уровне заголовка. База отсчёта — левый край блока заголовка
(каретка сдвигает текст вправо), обход ограничен: у последней свёрнутой группы границы за
ней нет, и первый видимый узел нашёлся через 107 сиблингов в чужой ветке формы.

Клик по заголовку группы теперь сперва скроллит цель в вид: цель кликается по координатам,
и ниже вьюпорта клик молча не доходил.

Ответ на клик отдаёт clicked.group (техническое имя) и clicked.title (текущий заголовок):
заголовок ключом быть не может — он повторяется между блоками формы и меняется при
раскрытии (CollapsedRepresentationTitle), после чего клик по прежнему тексту не находит
элемент. При смене заголовка hint говорит, чем кликать дальше.

Фикстура: вложенная группа первым ребёнком, «скрыт первый узел» в двух вариантах контрола,
группа с меняющимся заголовком в конце формы (уезжает за вьюпорт). Каждый кейс проверен на
красноту без своей правки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-28 16:12:07 +03:00
co-authored by Claude Opus 5
parent 006e64405c
commit d0e81a1715
7 changed files with 257 additions and 45 deletions
@@ -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), но промаха тут НЕ бывает: