Compare commits

...

263 Commits

Author SHA1 Message Date
Nick Shirokov ae82412377 feat(switch): добавить платформу Yandex Code Assistant (#22)
Code Assistant ищет навыки в .codeassistant/skills/ (приоритетнее .agents/).
Добавлен отдельный ключ codeassistant в реестр switch.py, пункт в
интерактивное меню, строка в таблицу README и две записи в матрицу
build-ports (powershell + python).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:07:24 +03:00
Nick Shirokov a38bb55bca chore(tests): refresh form-info/rich-form снапшот (пред-существующий несвежий)
Снапшот form-info отставал от давних сертифицированных фич form-compile (есть в
собственных снапшотах form-compile): <ShowTitle> у группы + полный AdditionSource
+ companion-панели у табличных дополнений (SearchString/ViewStatus/SearchControl)
+ каскадная перенумерация id. Контент не теряется — только добавления и сдвиг id.
Не связано с правками этой сессии (фейл воспроизводился и на пред-сессионном
компиляторе). Полный регресс: 427/427.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:53:01 +03:00
Nick Shirokov 1c21bef26c feat(form-compile): drop-on-miss warn для enum ориентации/behavior + выравнивание портов
Нераспознанное значение ориентации (group/columnGroup/page) и behavior теперь даёт
WARN с авторским набором допустимых значений — раньше промах по карте молча не
эмитил тег (тихая потеря). pass-through-ключи не трогаем (там verbatim, потери нет).

Заодно выровнен регистр enum-резолва ориентации между портами: py был
case-sensitive у columnGroup/page, ps1 (switch) — нет; теперь оба нечувствительны.

v1.172. Регресс form-compile 43/43 (ps1+py), form-validate 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:16:28 +03:00
Nick Shirokov 09d7097476 feat(form-compile): авторские references + индекс, корректность наборов значений
Каскад инструкции: ядро SKILL.md покрывает большинство задач, для редких/нишевых
конструкций — 12 тематических файлов в references/ (по индексу в SKILL.md).
Контракт references: только «как собрать DSL для задачи» — без механики эмиссии,
синонимов, авторезолва и forgiving (это тихая помощь модели); ссылки только внутри
навыка; область строго по элементу-владельцу.

Корректность ядра (наборы значений выверены по корпусу + доменно, forgiving/legacy
исключены из авторских):
- group расцеплён: ориентация (vertical/horizontalIfPossible/alwaysHorizontal) +
  отдельный ключ behavior (collapsible/popup) — popup-группы стали выразимы;
- titleLocation: полный набор none/left/right/top/bottom/auto;
- commandBarLocation += Bottom; searchStringLocation += Bottom/CommandBar/PullFromTop;
- общее свойство tooltip; events: null → авто-имя обработчика; правило уникальности имён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:16:22 +03:00
Nick Shirokov 717f3d8cc5 docs(form-decompile): сжать раздел «Что получаешь» + актуализировать ring3
Раздел разросся; ужал до сути (черновик, не обратим, теряет молча).
Исправлен устаревший факт: CommandInterface и ConditionalAppearance (без
scope) теперь поддерживаются (эмитятся), не валят скрипт. Падение — только
CA со scope / design-time диаграммы-планировщики / неизвестный элемент /
не-Form root. Убраны детали реализации (disable-model-invocation —
во фронтматтере) и нишевый абзац про GroupList.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:53:52 +03:00
Nick Shirokov e8b8d32e0d feat(form-decompile): Python-зеркало декомпилятора (порт ps1→py)
Полный порт form-decompile.ps1 v0.147 → .py (structure 1:1, как form-compile).
Двухпортовость декомпилятора замыкает dual-port для всего form-пайплайна.

Паритет ps1↔py: 1733/1733 байт-в-байт на list-iter.txt (все закрытые
кластеры), 0 расхождений, 0 крашей. Полный корпус 17k — в процессе.

Учтённые PowerShell-семантики (иначе тихие расхождения):
- `-eq`/`-ne` регистронезависимы → _ps_ieq на сравнениях заголовок↔авто-имя
  (title-суппресс: "Check date" == "check date")
- одноэлементный @() разворачивается при return без `,`-оператора
  (Build-DLInputParameters → inputParameters: объект при 1, массив при 2+)
- truthiness одноэлементного массива (@("") → falsy → дроп FunctionalOptions)
- .NET XmlDocument НЕ нормализует CRLF в InnerText (ET — нормализует):
  \r\n→&#13;\n внутри корня (не в прологе/эпилоге)
- порядок ключей .NET Hashtable (цвета) захвачен из PS 5.1, не из литерала
- [decimal] сохраняет масштаб vs [double] (Decimal в сериализаторе)

WS-стратегия: два читателя на одном ET-дереве (_text сворачивает
whitespace-only→"" как PreserveWhitespace=false; _text_ws — сырой для Resolve-WS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:28:14 +03:00
Nick Shirokov 2a8d594f66 fix(form-decompile,form-compile): DataSet field TypeLink/Folder/пустой dataPath
Поле набора динсписка (settings.fields[]) — три подвида, терявшиеся при раундтрипе
(форма ИнвентаризацияНМА/ФормаПодбораДокументовЗатрат: 18 diff-строк → 0):

1. inputParameters[].typeLink {field, linkItem} — связь по типу (dcscor:TypeLink,
   субконто с типом-от-счёта). Декомпилятор склеивал InnerText в строку
   ("СчётДт"+"1"="СчётДт1") → компилятор писал xs:string. Структурный захват + эмит.
2. folder: true — поле-папка (DataSetFieldFolder, группировка СубконтоДт над
   СубконтоДт1/2/3; без <field>). Ловился только NestedDataSet; компилятор хардкодил
   DataSetFieldField + всегда <field>.
3. пустой dataPath: "" — поле с <dcssch:dataPath/> + <field> (≠ дефолт dataPath==field).
   Декомпилятор дропал → компилятор реконструировал dataPath=field. Has-Child вместо
   $dp -and; явный dataPath (вкл. "") побеждает fallback (self-closing при "").

Зеркало py (ps1==py байт-в-байт), регресс 43/43 (ps+py), широкий прогон list-top:
match 25→26, TOTAL 445→427, 0 регрессий. Декомпилятор v0.147 / компилятор v1.171.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:28:40 +03:00
Nick Shirokov 06331a9b80 fix(form-decompile,form-compile): dataParameters мульти-value + SpellCheckingOnTextInput
(1) dcsset:dataParameters — параметр с НЕСКОЛЬКИМИ <dcscor:value> (valueListAllowed,
напр. два DesignTimeValue Перечисление.X) — декомпилятор читал ОДНО (SelectSingleNode),
2-е/3-е дропались. Фикс: SelectNodes → массив (декомпилятор) + ветка массива в
Emit-DataParameters (компилятор ps1+py, отдельный <dcscor:value> на каждое значение по типу).
(2) SpellCheckingOnTextInput (input) → GENERIC_SCALARS (обе стороны+py).

Формы Организации/ФормаСписка (dataParameters мульти-DesignTimeValue) + ВводАдреса
(SpellChecking) → match. ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:07:14 +03:00
Nick Shirokov 9ec5857e22 fix(form-compile): GUID.GUID значение → xr:DesignTimeRef (Normalize-ChoiceValue)
Значение параметра выбора (choiceParameters app:value) вида "GUID.GUID" (raw-ссылка по
метаданным.значение, оба GUID) эмитилось как xs:string: Normalize-ChoiceValue не
распознавал raw-GUID-ссылку → xs:string. Тот же класс, что choiceList DesignTimeRef-GUID
(commit 2d326c99), но другой потребитель.

Универсальный фикс: ветка GUID.GUID → xr:DesignTimeRef в Normalize-ChoiceValue (всегда
ссылка, не строка; named-ссылки Enum.X.Y детектятся ниже). Закрывает choiceParameters
и любой др. потребитель Normalize-ChoiceValue; choiceList не затронут (там явный
valueType побеждает Normalize). Зеркало py.

Форма НастройкиПрямыхВыплатФСС/ФормаЗаписи → match. ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:31:56 +03:00
Nick Shirokov b5e8e1df7a feat(form-decompile,form-compile): батч простых хвостов — generic-скаляры, form Scale, CommandBar HL Auto, CheckBox FooterDataPath
Хвост из указанных форм (по 1 в корпусе, кроме CheckBox FooterDataPath):
(1) GENERIC_SCALARS (обе стороны+py): AutoCorrectionOnTextInput (input) /
    CommandUniqueness (button bool) / AllowInputEmptyMultipleValues (input bool) /
    BehaviorOnHorizontalCompression (table).
(2) Форменный <Scale> (масштаб формы) → KNOWN_FORM_PROPS.
(3) CommandBar>HorizontalLocation: компилятор через Get-HLocation скипал Auto
    (умолчание дополнений), но CommandBar хранит его фактически (декомпилятор ловит
    только при наличии) → эмит фактический, включая Auto. Зеркало py.
(4) CheckBoxField>FooterDataPath/FooterText — общие cell-свойства колонки, не ловились
    у check (как раньше расширяли на picField). Захват + эмит (ps1+py).

Выборка 9 форм: match 7/9 (остаток 2 — InputField>MultipleValuesFont структурный font
[отложен] + app:item>Value DesignTimeRef-GUID). ps1==py байт-в-байт. Регресс 43/43. Spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:25:37 +03:00
Nick Shirokov 95d8ece309 fix(form-decompile,form-compile): use:false на группе фильтра (FilterItemGroup)
Группа условий фильтра <dcsset:item xsi:type="dcsset:FilterItemGroup"> может нести
<dcsset:use>false</dcsset:use> (группа отключена, в т.ч. пустая OrGroup без детей).
Декомпилятор ловил group/items/presentation/viewMode/userSettingID, но НЕ use →
терялось; компилятор не эмитил.

Декомпилятор: захват use:false на группе. Компилятор: emit <dcsset:use>false</dcsset:use>
перед <groupType> (порядок исходника). Зеркало py. Корпус: 6 форм.

Форма ДокументооборотСКонтролирующимиОрганами/ПоказСообщений → match. ps1==py
байт-в-байт. Регресс 43/43. Spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:11:38 +03:00
Nick Shirokov 26c804391a fix(form-decompile): фильтр right xs:string "1"/дата — явный valueType (авто-детект дал бы число/дату)
Значение фильтра <dcsset:right xsi:type="xs:string">1</dcsset:right> терялось как тип:
декомпилятор исключал xs:-типы из захвата valueType (расчёт на авто-детект компилятора),
но компилятор авто-детектит строку "1" как xs:decimal (число) → xs:string-ность терялась.

Принцип (подтверждён пользователем): когда авто-вывод типа компилятором дал бы ДРУГОЙ
тип, чем фактический — декомпилятор должен указать valueType явно. Фикс: при xs:string +
значение-строка матчит числовой/дату-паттерн (что компилятор детектит иначе) →
фиксируем valueType="xs:string". Компилятор honors явный тип.

Корпус: 8 значений в 3 формах. Форма ЭлектронныйЗаказЗаявка/ТитулГрузоотправителя →
match. Декомпилятор-only. ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:05:41 +03:00
Nick Shirokov 4855f79403 fix(form-decompile,form-compile): значения параметров дин-списка — ent:-тип, multi-value, dataParameters в partial-дескрипторе
Форма ПомощникРасчетаНалогаУСН теряла значения параметров дин-списка (3 бага):
(1) ent: системное перечисление в значении (ent:AccumulationRecordType=Expense) →
    компилятор понижал до xs:string. Фикс: ветка ^ent: в Emit-DLValue/emit_dl_value
    (value несёт тот же xsi:type, что valueType).
(2) Параметр с valueListAllowed + НЕСКОЛЬКО <dcssch:value> — декомпилятор читал ОДНО
    (SelectSingleNode), 2-е/3-е дропались. Фикс: SelectNodes → массив (компилятор уже
    эмитит array через Emit-DLValue по каждому).
(3) ListSettings с <dcsset:dataParameters> ронялся в канон-fallback (Get-ListSettingsShape
    unknown top-level → $null) → компилятор додумывал полный канон (лишние userSettingID/
    itemsUserSettingID). Фикс: dataParameters → дескриптор + case в partial-пути (ps1+py),
    контент из settings.dataParameters.

Форма → match (8 diff → 0). Корпус: ent:/multi-value по 1 форме (редко). ps1==py
байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:01:52 +03:00
Nick Shirokov 4434493446 fix(form-decompile): whitespace-контент input ML (footerText/warningOnEdit/inputHint/nonselectedPictureText)
FooterText с whitespace-контентом (`<v8:content>\n</v8:content>` — blank-footer, оба
языка) схлопывался в пустой `<v8:content/>`: декомпилятор читал через Get-LangText,
PreserveWhitespace=false стрипал → "" → компилятор эмитил self-closing. Тот же
whitespace-ML корень, что у Attribute>Title / choiceList presentation / Column>Title.

Фикс: input ML-чтения footerText (3) / warningOnEdit (4) / inputHint (1) /
nonselectedPictureText (2) → Get-LangTextWS (восстанавливает значимый пробел/перенос
из WS-дока; безопасный суперсет — для непустого контента идентичен). Многострочный
`\n`-контент раундтрипится (Esc-Xml сохраняет перенос, тег спанит строки как оригинал).

Корпус: 2 формы (НастройкиОтправкиЭДО acc+erp). Проверка 25 форм с FooterText/
WarningOnEdit/NonselectedPictureText: match 25/25, 0 dec-fail. Декомпилятор-only.
ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:48:49 +03:00
Nick Shirokov 47e980f932 feat(form-decompile,form-compile): itemsUserSettingPresentation в дескрипторе ListSettings
ListSettings может нести items-уровневую подпись <dcsset:itemsUserSettingPresentation>
(рядом с itemsViewMode/itemsUserSettingID). Get-ListSettingsShape ронял её в канон-fallback
(unknown top-level element → return $null) → терялась. Аналог container-level
userSettingPresentation (commit 66817312), но items-уровень.

Декомпилятор: захват itemsUserSettingPresentation в дескриптор (Get-PresByType — форма
по xsi:type). Компилятор: новый case в потреблении дескриптора (Emit-USPresentation /
emit_us_presentation). Зеркало py.

Корпус 8.3.24: 2 формы (ОплатаПлатежнойКартой/ФормаПлатежиПоРеестрам, …): match 0→2,
TOTAL→0. ps1==py байт-в-байт. Регресс 43/43. Spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:39:00 +03:00
Nick Shirokov 6cfc504509 feat(form-decompile,form-compile): DisplayImportance форменного AutoCommandBar
Форменная командная панель (<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">) может
нести DisplayImportance="Low"/"VeryLow" (адаптивная важность). Декомпилятор не захватывал
этот атрибут, маркер autoCmdBar создавался только при halign/autofill-false/children →
DisplayImportance терялся; компилятор не эмитил.

Декомпилятор: захват $acb DisplayImportance + расширен гейт маркера (DI тоже триггерит
сохранение autoCmdBar-элемента). Компилятор: DI-Attr на тег форменного AutoCommandBar
(обе ветки open/self-closing). Зеркало py. Корпус: 11 форменных ACB с DisplayImportance.

Выборка 11 форм (ПерепискаСКонтролирующимиОрганами/ФормаГрупповойОтправки, …):
match 11/11, TOTAL→0. ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:21:44 +03:00
Nick Shirokov 831c80d9f0 feat(form-decompile,form-compile): батч — form Enabled, PictureField EnableDrag, UsualGroup CurrentRowUse, Column FillCheck
Четыре «расширить существующее на другой тип» свойства из свежего iter-прогона:
(1) Форменное <Enabled>false</Enabled> (доступность всей формы, 6 форм) → KNOWN_FORM_PROPS
    (декомпилятор; компилятор авто-PascalCase Emit-Properties уже эмитит).
(2) PictureField>EnableDrag (4) — как PictureDecoration: декомпилятор ловил generic-ом,
    но Emit-Layout не эмитит EnableDrag → явный emit в Emit-PictureField (после Emit-Layout).
(3) UsualGroup>CurrentRowUse (7) — как Pages: захват в обработчике UsualGroup + Emit-Group
    (после Representation).
(4) Column>FillCheck (6) — как у реквизита: захват в Decompile-AttrColumn + Emit-AttrColumn
    (после Type; bool true→ShowError / строка verbatim, синоним fillChecking).

Зеркало py (2/3/4; декомпилятор ps1-only). Выборка 18 форм: match 18/18, TOTAL→0.
ps1==py байт-в-байт. Регресс 43/43. Spec обновлён (enabled/group currentRowUse/column fillCheck).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:10:20 +03:00
Nick Shirokov db341f2351 fix(form-decompile,form-compile): UUID-ссылка (N/M:GUID) в Save/UseAlways теряла/получала префикс
Поле-ссылка по UUID (1/0:GUID) обрабатывается как путь-с-точкой: компилятор НЕ
реинъектит префикс "имя." (платформа хранит её без префикса). Два места рассогласованы:

(1) Save (декомпилятор) — продолжение фикса 2abaa28f: снимали префикс "имя." и у
UUID-остатка (1/0:GUID без точки матчил [^.]+$), компилятор не возвращал → потеря.
Добавлен guard `$matches[1] -notmatch '^\d+/\d+'` → UUID-путь храним полным.

(2) UseAlways (компилятор ps1+py) — реинъектил "имя." к UUID-полю без префикса
(1/0:GUID → Объект.1/0:GUID), оригинал хранит без префикса. Добавлен guard
`-notmatch '^\d+/\d+'` (зеркало правила Save-компилятора). Корпус: 1 форма
(ПланВнутреннихПотреблений/ФормаДокумента, useAlways UUID no-prefix).

Форма → match. ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:47:52 +03:00
Nick Shirokov 2abaa28f16 fix(form-decompile): Save Field — многоуровневый путь теряет префикс реквизита
Реквизит с <Save><Field>имя.Settings.Filter</Field> (напр. SettingsComposer):
декомпилятор снимал префикс "имя." ВСЕГДА (regex `(.+)`) → "Settings.Filter", но
компилятор реинъектит префикс ТОЛЬКО для полей без точки (dot-правило: путь с точкой =
полный, как есть). Рассогласование → префикс реквизита терялся при раундтрипе.

Фикс (декомпилятор): снимаем префикс "имя." только когда остаток — простое под-поле
без точки (`([^.]+)$`); многоуровневый путь "имя.X.Y" храним ПОЛНЫМ → компилятор
по dot-правилу эмитит как есть. Period-кейс (одноуровневые EndDate/StartDate/Variant)
не затронут.

Корпус 8.3.24: 366 многоуровневых Save-полей в 89 формах. Выборка 40 форм: match 40/40,
0 регрессий (включая Period). Декомпилятор-only. Регресс 43/43. Spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:30:26 +03:00
Nick Shirokov 03720d93ed feat(form-decompile,form-compile): AutoShowOpen/ClearButtonMode (input) + EnableDrag на PictureDecoration
(1) AutoShowOpenButtonMode (input, enum Auto/Always/FilledOnly, 14) +
AutoShowClearButtonMode (3) — листовые скаляры → GENERIC_SCALARS (обе стороны + py).

(2) PictureDecoration>EnableDrag (7) — декомпилятор ловил generic-ом (Add-CommonProps),
но EnableDrag эмитился ТОЛЬКО в Emit-Table/SpreadSheet (Emit-Layout его не выводит) →
PictureDecoration терял. Добавлен явный emit в Emit-PictureDecoration (после Emit-Layout).
Generic-перенос enableDrag в Emit-Layout отклонён: сдвигает позицию в сертифицированных
Table/SpreadSheet-снэпшотах (EnableDrag может быть XDTO-позиционно-чувствителен, как
HeaderHeight/CurrentRowUse) — точечный фикс безопаснее.

Выборка 22 формы: match 19 (целевые AutoShow*/PictureDecoration>EnableDrag закрыты;
остаток 3 — SpellCheckingOnTextInput + value). ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:22:10 +03:00
Nick Shirokov 90d2649a5f fix(form-compile): companion наследовал DisplayImportance владельца (PowerShell dynamic scope)
Emit-Companion / Emit-CompanionPanel вызывали DI-Attr $el, но $el НЕ их параметр —
PowerShell брал его из родительского скоупа (эмитируемого элемента). Поэтому
авто-генерируемые companion (ExtendedTooltip/ContextMenu/AutoCommandBar с name="@")
наследовали DisplayImportance владельца (CheckBoxField/UsualGroup/Table), которого
в оригинале у них нет → ложный ADDED. Корпус: ExtendedTooltip/ContextMenu НИКОГДА не
несут DisplayImportance, AutoCommandBar — только element-level (11), не companion.

Фикс: DI-Attr от СОБСТВЕННОГО объекта компаньона ($content / $panel), не от ambient
$el. Python не имел dynamic-scope-бага (di_attr на companion не эмитил вовсе), но для
паритета добавлен di_attr(content/panel) — оба рантайма теперь идентичны (companion
без собственного DI → пусто).

Выборка 19 форм (СостоянияОригиналовПервичныхДокументов acc+erp, + формы с
DisplayImportance-владельцами): match 18, ADDED DisplayImportance исчез. ps1==py
байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:04:33 +03:00
Nick Shirokov e1fb40189c feat(form-decompile,form-compile): Popup>CommandSource + InputField multiple-value/itemWidth скаляры
(1) Popup>CommandSource — источник команд попапа (Form/FormCommandPanelGlobalCommands/
Item.X) не ловился/эмитился (был только у ButtonGroup/CommandBar). Добавлен в
обработчик Popup (декомпилятор, с тем же id-ссылка guard) + Emit-Popup (после Title/
ToolTip, перед компаньоном). Зеркало py.

(2) Листовые скаляры в GENERIC_SCALARS (обе стороны + py): ItemWidth (radio/check,
22), ShowCheckBoxesInDropList (input bool, 7), MultipleValueDataPath /
MultipleValuePresentDataPath (input, по 10) + хвост множественного выбора
MultipleValuesTextColor/BackColor (цвет — текст-контент) / MultipleValuePictureShape /
MultipleValuePictureDataPath (input, по 1).

Выборка 41 форма: match 35 (целевые категории ItemWidth/ShowCheckBoxes/
MultipleValue*/Popup>CommandSource закрыты; Контрагенты/ФормаВыбора → match).
ps1==py байт-в-байт. Регресс 43/43. Spec обновлён (commandSource +popup).
Остаток 6 форм — отдельные кластеры (MultipleValuesFont структурный, CA-whitespace
value, DataSet field, DisplayImportance на companion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:57:00 +03:00
Nick Shirokov 2d326c99a5 feat(form-decompile,form-compile): choiceList значение — DesignTimeRef по GUID + nil
Значение элемента <ChoiceList> (InputField/RadioButtonField):
(1) <Value xsi:type="xr:DesignTimeRef">GUID.GUID</Value> — ссылка по метаданным-GUID
(raw, не по имени) эмитилась как xs:string: декомпилятор исключал DesignTimeRef из
valueType (расчёт на авто-детект компилятора), но Normalize-ChoiceValue детектит только
named-ссылки (Enum.X.Y), GUID.GUID → xs:string. Фикс: декомпилятор сохраняет
valueType="xr:DesignTimeRef" при значении-GUID (по префиксу GUID); named-ссылки
по-прежнему авто-детектятся.
(2) <Value xsi:nil="true"/> — nil-значение варианта эмитилось как typed-empty xs:string
(Convert-TypedValue пустого nil-узла → ""). Фикс: декомпилятор ставит valueType="nil",
компилятор эмитит <Value xsi:nil="true"/>.

Зеркало py. Выборка 15 форм (ИндексацияЗаработка/ФормаДокумента, РассылкиОтчетов, …):
match 13→15 целевых (остаток 2 формы — отдельный кластер dcsset:left булев-литерал).
ps1==py байт-в-байт. Регресс 43/43. Spec обновлён (choiceList valueType nil/DesignTimeRef).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:41:17 +03:00
Nick Shirokov 8465bbc82e fix(form-compile,form-decompile): ManualQuery=false при наличии QueryText (отклонение эвристики)
Компилятор форсил <ManualQuery>true</ManualQuery> всегда при наличии query (hasQuery →
true). Но платформа изредка хранит QueryText при ManualQuery=false (корпус: 16 форм
query+mainTable+manualQuery=false, против 2447 query+manualQuery=true) — список с
сохранённым авто-запросом, но не в «ручном» режиме.

Декомпилятор: фиксирует manualQuery ТОЛЬКО при отклонении от эвристики hasQuery
(query есть, но ManualQuery=false → settings.manualQuery=false). Компилятор: явный ключ
manualQuery (в т.ч. false) ПОБЕЖДАЕТ эвристику; различает present-false от absent
(раньше $st.manualQuery -eq $true трактовал явный false как absent → forced true). Зеркало py.

Выборка 16 форм (ОснованияЛьготПоИмущественнымНалогам/ФормаВыбора, … acc+erp):
match 0→16, TOTAL→0. ps1==py байт-в-байт. Регресс 43/43. Spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:25:55 +03:00
Nick Shirokov 8eedca4c22 feat(form-compile,form-decompile): typed-empty значение параметра дин-списка (xs:string vs nil) + SettingsStorage
(1) Пустое значение schema-параметра дин-списка: компилятор ВСЕГДА эмитил
<dcssch:value xsi:nil="true"/>, но платформа часть пустых строковых параметров пишет
типизированным пустым <dcssch:value xsi:type="xs:string"/> (корпус: 27 typed-empty,
все xs:string; 255 nil). Решается ФОРМОЙ value, не valueType: декомпилятор различает
(<value xsi:type="xs:string"/> → value:"", <value xsi:nil/> → ключ опущен/null —
Convert-TypedValue пустого xs:string даёт ""). Компилятор: при value:"" (явная пустая
строка, тип отсутствует или string) → typed-empty xs:string, НЕ nil. Ветка ПЕРЕД vla-nil
(решение не зависит от valueListAllowed). Зеркало py.

(2) SettingsStorage — форменное свойство (ссылка на хранилище настроек, корпус 11) →
KNOWN_FORM_PROPS (декомпилятор; компилятор авто-PascalCase Emit-Properties уже эмитит).

Выборка 17 форм: match 13→15 (типовая МашиночитаемыеДоверенности — 18 typed-empty,
была вся в nil → match). ps1==py байт-в-байт. Регресс 43/43. Spec обновлён.
Остаток 2 формы (другие value-подвиды): DesignTimeValue в dcscor-контексте дропнут;
пустой LocalStringType self-closing vs пара — отдельные находки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:18:37 +03:00
Nick Shirokov 670a574249 feat(form-decompile,form-compile): оформление заголовка + CurrentRowUse на Pages
Контейнер вкладок <Pages> может нести оформление заголовка (TitleFont/TitleTextColor/
TitleBackColor/…) и <CurrentRowUse>. Декомпилятор оформление УЖЕ захватывал (через
Add-CommonProps→Add-Appearance), но Emit-Pages не вызывал Emit-Appearance → терялось.
CurrentRowUse не ловился у Pages (только Table).

Компилятор: Emit-Appearance (профиль field, как у Page) после Emit-Layout +
CurrentRowUse после PagesRepresentation (порядок XSD). Декомпилятор: захват
currentRowUse в обработчике Pages. Зеркало py. currentRowUse → allowlist (ps1+py).

Корпус 8.3.24: Pages title-appearance ~5, CurrentRowUse ~3. Выборка 8 форм
(КлиентБанк/ЗагрузкаВыписки, Контрагенты/ФормаНовогоЭлемента, … acc+erp):
match 0→8, TOTAL→0. ps1==py байт-в-байт. Регресс 43/43. Spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:48:19 +03:00
Nick Shirokov e3ae9c27d1 feat(form-decompile,form-compile): пустой/whitespace right фильтра + GetInvisibleFieldPresentations (кластер Attribute>right)
(1) Пустой <dcsset:right xsi:type="xs:string"/> ≠ отсутствие <right>: декомпилятор
схлопывал оба в shorthand-маркер `_`, а компилятор для shorthand `_` не эмитит right
вовсе → пустой right терялся (Get-FilterValueWithType маппит наличие пустого/nil right
в '_', отсутствие → $null — РАЗЛИЧИМЫ). Фикс (декомпилятор): при value='_' с реально
присутствующим <right> форсим объектную форму {value:"_"} — компилятор эмитит
self-closing right (ветка `_` уже была). Заодно whitespace-/пробельные значения,
рвущие shorthand-парсинг (split по пробелам), уходят в объектную форму.

(2) Whitespace-only <right>   </right> (9 пробелов): PreserveWhitespace=false стрипал
в '' → '_' → self-closing. Восстанавливаем реальные пробелы из WS-дока (Resolve-WS,
как у whitespace-заголовков) → объектная форма value="   ".

(3) GetInvisibleFieldPresentations — Settings-скаляр дин-списка (после MainTable;
дефолт true, корпус 20/20 = false → эмит отклонения). Захват/эмит факт. значения,
зеркало py.

Выборка 14 форм (ДоговорыКонтрагентов, ЕдиницыГенерирующие×2, РаботаСНоменклатурой,
ПравилаИнтеграции, … acc+erp): match 0→14, TOTAL→0. ps1==py байт-в-байт. Регресс 43/43.
Spec обновлён (getInvisibleFieldPresentations). (1)/(2) — декомпилятор-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:37:39 +03:00
Nick Shirokov 85ae72739f feat(form-decompile,form-compile): FooterText/FooterDataPath на PictureField
PictureField (поле картинки в таблице) может нести <FooterText> (ML-текст подвала
колонки) и <FooterDataPath> — общие cell-свойства колонки, уже поддержанные у
input/labelField, но не у PictureField. Декомпилятор не захватывал, компилятор не
эмитил → терялось (форма УчётныеЗаписиДокументооборота: двуязычный FooterText
«Доступность ЭП»/«Digital signature availability»).

Декомпилятор: захват footerDataPath/footerText в обработчике PictureField (зеркало
input/labelField). Компилятор: эмиссия после Emit-Layout (как у input). Зеркало py.
Ключи уже в allowlist (общие cell-props).

Корпус 8.3.24: PictureField FooterText = 4 формы. Выборка 4 формы
(УчётныеЗаписиДокументооборота acc+erp, ЗаказМатериаловВПроизводство,
СчётФактураВыданный): match 0→4, TOTAL→0. ps1==py байт-в-байт. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:16:04 +03:00
Nick Shirokov 668173121d feat(form-decompile,form-compile): userSettingPresentation контейнера ListSettings (filter/order/CA)
Контейнер настроек компоновщика (<dcsset:filter>/<order>/<conditionalAppearance>)
может нести собственный <dcsset:userSettingPresentation> — кастомную подпись
пользовательской настройки (после userSettingID). Декомпилятор кодировал контейнер
только как блок-мету "vu"/"u"/"v" (viewMode/userSettingID), теряя presentation;
компилятор не эмитил.

Дескриптор listSettings[tag] теперь — строка-код "vu" ИЛИ объект
{ meta:"vu", presentation:<текст/{ru,en}> }. Декомпилятор: Get-PresByType сохраняет
форму по xsi:type (ru-only LocalString ≠ xs:string). Компилятор: новый параметр
blockUserSettingPresentation в Emit-Filter/Order/ConditionalAppearance (+ в гейт
hasBlockMeta — иначе контейнер только-с-presentation, без items/viewMode/userSettingID,
не эмитился). Зеркало py.

Корпус 8.3.24: 6 контейнеров-presentation в 6 формах. Выборка 6 форм
(ОтветственныеЗаАктуализацию/ЗаПодписание acc+erp, ПравилаФормированияРезервов,
СтавкиНДСНоменклатуры): match 0→6, TOTAL→0. ps1==py байт-в-байт. Регресс 43/43.
Spec обновлён. Cert: раундтрип (формат платформы, позиция как в оригинале).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:03:16 +03:00
Nick Shirokov 9af86b7810 feat(form-decompile,form-compile): ColumnGroup HeaderDataPath + HeaderFormat
Группа колонок таблицы может нести динамический заголовок из данных
(<HeaderDataPath>путь</HeaderDataPath>) и формат заголовка (<HeaderFormat>, ML-текст).
Декомпилятор не захватывал, компилятор не эмитил → теряло (14 строк на форме
БольничныйЛист/ФормаПодробнееОРасчете, 2 ColumnGroup'ы).

Ключи на columnGroup: headerDataPath (path-скаляр), headerFormat (ML — строка/{ru,en}).
Эмиссия в общем cell-блоке Emit-Layout: headerDataPath перед HeaderHorizontalAlign,
headerFormat после (порядок XSD, рядом с уже сертифицированным HeaderHorizontalAlign).
Добавлены в allowlist knownKeys (ps1+py).

Корпус 8.3.24: HeaderDataPath/HeaderFormat = по 2 (обе в этой форме — редкий край).
Форма → match (TOTAL 14→0). Зеркало py байт-в-байт (сверено нормализованным diff).
Регресс 43/43 (ps1+py). Spec обновлён (раздел columnGroup). Cert: раундтрип +
смежность с сертиф. HeaderHorizontalAlign в том же эмит-блоке.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:48:07 +03:00
Nick Shirokov dc56ef6899 fix(form-compile): MobileDeviceCommandBarContent с пустым значением (PS array-unwrap + self-closing)
12 форм корпуса несут MobileDeviceCommandBarContent с одним ПУСТЫМ item
(<xr:Value xsi:type="xs:string"/>, не имя). Декомпилятор захватывал
mobileCommandBarContent: [""], но компилятор не эмитил блок:

(1) PS-ловушка: гейт `if ($def.mobileCommandBarContent -and ...)` — одноэлементный
массив @("") в boolean-контексте разворачивается в "" → falsy → блок пропущен.
Фикс: $null-проверка вместо truthy ($null -ne ... -and Count -gt 0).
(2) Пустое значение → самозакрывающийся <xr:Value xsi:type="xs:string"/> (зеркало платформы).

Python не имел unwrap-ловушки ([""] truthy), но self-closing добавлен для байт-паритета
(+ is not None гейт для единообразия).

Выборка 9 форм (РасширенныйВводКонтактнойИнформации, ХранилищеВариантовОтчетов×3,
ФормаНастроекОтчета, ИнтерфейсДокументовЭДО, ПользовательскиеМакетыПечати, …):
match 0→9, TOTAL→0. Регресс 43/43 (ps1+py). Блок именованных значений (148 форм) уже
был сертифицирован; пустой — тот же блок с пустым значением (формат платформы).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:12:51 +03:00
Nick Shirokov a41a6d822b fix(form-decompile): whitespace-заголовок колонки реквизита (Get-LangTextWS)
Продолжение систематической чистки whitespace-ML: колонка реквизита (ValueTable,
Decompile-AttrColumn) с whitespace-only <Title> (<v8:content> </v8:content>) теряла
пробел через Get-LangText → "" → компилятор эмитил пустой <Title/>. Тот же фикс
Get-LangText → Get-LangTextWS (декомпилятор-only, безопасный суперсет — пробел
восстанавливается только когда контент-узел есть, но пуст).

Корпус 8.3.24: 4 whitespace-заголовка колонок в 4 формах. Выборка 4 формы: match 0→4,
TOTAL→0. Регресс не затронут (декомпилятор-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:07:52 +03:00
Nick Shirokov 8631026259 fix(form-decompile,form-compile): whitespace-presentation choiceList + SpecialTextInputMode (кластер RadioButtonField>Presentation)
(1) Presentation элемента <ChoiceList> (переключатель / поле ввода) с whitespace-only
контентом (<v8:content> </v8:content>, пробел) терялась: Decompile-ChoiceList читал
presentation через Get-LangText → "" (суппресс-маркер) → компилятор эмитил пустой
<Presentation/>. Фикс: Get-LangText → Get-LangTextWS (тот же корень, что был у
Attribute>Title; декомпилятор-only — Emit-ChoicePresentation " " уже умеет, IsNullOrEmpty
пропускает пробел в контент-ветку). Кластер снова раздут harness-мис-атрибуцией:
одна реальная whitespace-потеря на форму, generic строки-обёртки (<Presentation>/
<v8:item>/<v8:lang>ru) сыпались ложным LOST под соседними элементами.

(2) SpecialTextInputMode (Email/PhoneNumber — моб. спец-режим ввода input) → GENERIC_SCALARS
(компилятор ps1+py, декомпилятор ps1), листовой enum pass-through.

Выборка 3 формы (Новости/ФормаПросмотраНовостейРабочийСтол, МастерПереходаВОблако,
ПеремещениеОС/ФормаДокумента): match 0→3, TOTAL→0. Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:02:12 +03:00
Nick Shirokov e127dfcf3d fix(form-decompile): whitespace-заголовок реквизита (Get-LangTextWS) — кластер Attribute>Title
Реквизит формы с whitespace-only <Title> (<v8:content> </v8:content>, одиночный
пробел) терялся: декомпилятор читал заголовок через Get-LangText, а PreserveWhitespace=false
стрипал пробел → "" (= суппресс-маркер «нет заголовка») → компилятор не эмитил Title.

Фикс: Get-LangText → Get-LangTextWS (существующий хелпер, восстанавливает значимый
пробел — как уже сделано для UsualGroup Title/ToolTip). Декомпилятор-only: компилятор
" "-заголовок уже умеет (прецедент групп). Для непустого/мультиязык-контента поведение
не меняется (Get-LangTextWS == Get-LangText).

Корпус 8.3.24: 13 whitespace-заголовков реквизита в 10 формах. Кластер Attribute>Title
(impact 42) был раздут harness-мис-атрибуцией: одна реальная потеря на форму, а
generic строки-обёртки (<Title>/<v8:item>/<v8:lang>ru) сыпались ложным LOST под
соседними реквизитами. Выборка 5 форм: match 0→5, TOTAL→0. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:55:06 +03:00
Nick Shirokov ed2339a4bc feat(form-compile): значение v8:Type «Неопределено» — локальный xmlns на теге (фильтр + параметр дин-списка)
Значение типа v8:Type (на практике всегда <prefix>:Undefined — тип «Неопределено»
из namespace http://v8.1c.ru/8.2/data/types, префикс авто d6p1/d8p1/dN…) эмитилось
без объявления namespace → битый QName; а в параметре дин-списка компилятор вообще
ронял v8:Type → xs:string.

Корпус 8.3.24: 11 тегов (6 <dcsset:right> фильтра + 5 <dcssch:value> параметра),
значение всегда prefix:Undefined, ns всегда data/types. Топ ROOT-пробел нового
baseline (Attribute>value 48 LOST + 44 ADDED).

Фикс: хелпер Get-ValueTypeNsAttr / _value_type_ns_attr (объявляет xmlns:<pref> для
не-стандартного префикса при valueType v8:Type) в обе ветки Emit-FilterItem
(скаляр + массив op `in`) + новая ветка v8:Type в Emit-DLValue / emit_dl_value.

Выборка 7 форм (Взаимодействия acc/erp, ЖурналОпераций×3, ДокументЭДОБЗК, ЧекиККМ):
match 0→6, TOTAL→0. Зеркало py байт-в-байт, регресс 43/43 (ps1+py). Раундтрип
восстанавливает точные исходные байты платформы (её собственный формат — cert не нужен).
Spec обновлён (раздел filter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:39:30 +03:00
Nick Shirokov 71ecec6594 fix(form-decompile): ссылки на члена формы по id (N:uuid) — игнорировать с предупреждением, не verbatim
groupList/customSettingsFolder/userSettingsGroup/commandSource в форме "N:uuid" — ссылка на
член формы по id. Наш компилятор переназначает id → verbatim указал бы НЕ ТУДА (тихая порча).
Резолв N→имя ненадёжен: N не всегда соответствует named-элементу (форма ДенежныеДокументы:
GroupList=2:uuid, но элемента id=2 НЕТ; в конфигураторе список пустой — платформа сама не
разрешает эту dangling-ссылку; uuid константный для всех форм). «Страна id=2» в бэклоге —
совпадение.

Решение: декомпилятор захватывает только ИМЯ-форму; "N:uuid" опускает с предупреждением
(stderr) — задаётся вручную через form-edit. Результат идентичен (dangling → пустой список),
но без мусорной ссылки. Имя-форма (GroupList 8, CSF 18, UserSettingsGroup 3127, CommandSource
5116 в корпусе) round-трипится как есть.

Гард `^\d+:[0-9a-fA-F]{8}-` в 4 точках захвата. Harness стрипает "N:uuid"-форму (намеренное
непокрытие). Выборка 118 форм: match 113/118, ref-тег потерь 0, регрессий 0. Отменяет
verbatim-захват CustomSettingsFolder из dd32d2a6 для id-формы (имя остаётся).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:08:12 +03:00
Nick Shirokov dd32d2a6ca feat(form-decompile): CustomSettingsFolder — группа пользовательских настроек компоновщика
Форменное свойство формы отчёта со СКД <CustomSettingsFolder> — имя группы, куда
генерируются пользовательские настройки компоновщика (1С: «Группа пользовательских
настроек»). Декомпилятор не ловил → терялось (23 формы, напр. ИсторияРазмераПриложения).

Декомпилятор-only: +CustomSettingsFolder в KNOWN_FORM_PROPS. Компилятор уже эмитит
(emit_properties авто-PascalCase). Значение: имя группы (18) или N:<GUID> ссылка по id (5,
verbatim — как уже принятый GroupList). Ключ customSettingsFolder.

Выборка 23 формы: match 23/23, CustomSettingsFolder-потерь 0. Валидация раундтрипом
(decompiler-only). Регресс не затронут (только новый захват).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:30:15 +03:00
Nick Shirokov 227423ee1f feat(form-decompile,form-compile): KeyType/KeyField набора динамического списка (запросный список)
Запросный динамический список (без MainTable) задаёт ключ набора: <KeyType>
(FieldValue/RowKey/RowNumber) + <KeyField>* (0+ полей) — после Parameter*, до MainTable.
Декомпилятор не ловил → терялось (21 форма, напр. ВыборПрисоединенногоФайла).

DSL: settings.keyType (строка-enum) + settings.keyFields (массив). Взаимоисключающи с
mainTable (запросный список vs таблично-ориентированный — 1С: KeyField+MainTable ломает
пути данных списка). Декомпилятор: захват KeyType + всех KeyField; компилятор (ps1+py):
эмит после Emit-DLParameters, до MainTable (позиция из корпус-сигнатур).

Выборка 22 формы: match 17/22, KeyType/KeyField-потерь 0 (остаток — др. кластеры:
CheckBox ItemWidth, order-use, SearchControlAddition, empty-right), регрессий 0.
Регресс 43/43, ps1==py. Cert: corpus round-trip (запросные списки — 22 shipped-формы
грузятся; синтетический кейс = полный query-based список с Table, непропорционально
для verbatim 2-тег; MainTable+KeyField несовместимы → к dynamic-list-form не добавить).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:25:19 +03:00
Nick Shirokov c0487c51b7 feat(form-decompile,form-compile): Button Parameter (параметр команды кнопки)
Кнопка команды может нести <Parameter> (после CommandName) — параметр команды:
- xr:MDObjectRef (20 в корпусе 8.3.24): ссылка на объект метаданных, напр.
  DocumentJournal.Взаимодействия (команда ShowInList «Показать в списке»);
- v8:TypeDescription (16): описание типа <v8:Type>cfg:DocumentRef.X</v8:Type>
  (команда CreateByParameter «Создать по параметру»).
Декомпилятор не ловил → терялось (форма ЭлектронноеПисьмоИсходящее и др.).

DSL: ключ button.parameter (синоним «параметр»), дизамбигуация по форме значения —
строка → MDObjectRef (verbatim), объект {type} → TypeDescription (грамматика типа,
переиспользует Emit-Type с tag=Parameter). Декомпилятор: MDObjectRef → строка,
TypeDescription → {type} (Decompile-Type). Позиция: после CommandName.

Выборка 16 форм с Button Parameter: match 16/16, 0 потерь (оба вида). Кейс commands
(+кнопка с параметр:{type:CatalogRef} через рус-синоним) сертифицирован загрузкой в 1С —
позиция Parameter и синоним подтверждены. MDObjectRef-вариант: та же позиция эмиссии +
corpus round-trip (ShowInList требует list-контекст, синтетически не воспроизвести).
Регресс 43/43, ps1==py. parameter в knownKeys allowlist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:07:41 +03:00
Nick Shirokov 934462f4d2 fix(form-compile): Save Field — не префиксовать ссылку вида N/M без двоеточия
Поле <Save><Field> вида N/M (ссылка на элемент/колонку, напр. 5/0, 5/1) без двоеточия
получало лишний префикс имени реквизита (ДатаОкончанияПериода.5/0) — условие «оставить
как есть» ловило только N/M: с двоеточием (^\d+/\d+:). Форма ОтражениеДокументовВМеждународномУчете.

Фикс: ^\d+/\d+: → ^\d+/\d+ (bare N/M тоже как есть). Корпус 8.3.24: N/M bare 10, N/M: 746
(уже обрабатывался). Декомпилятор симметричен (strip префикса имя. иначе как есть) — не менялся.
Форма → match. Снэпшоты не затронуты (N/M в кейсах не встречается), регресс 43/43 (ps1+py).
Валидация раундтрипом shipped-формы (грузится в 1С; N/M — платформенная ссылка, эмитим verbatim).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:45:49 +03:00
Nick Shirokov 092f30a663 fix(form-decompile): точное число пробелов в whitespace-only <v8:content> декораций-распорок
LabelDecoration-распорки/отступы несут whitespace-only Title (<v8:content>   </v8:content>,
N пробелов) — у части (10/23 в корпусе 8.3.24) нет Width/stretch, и число пробелов = реальная
ширина выравнивания (не рудимент). PreserveWhitespace=false стрипал whitespace-only content в ""
→ Get-LangTextWS восстанавливал ОДИН пробел → терялось число (оригинал 3 пробела, regen 1).

Фикс (декомпилятор-only): второй XmlDocument с PreserveWhitespace=true (основной парс не трогаем,
нулевой риск); Resolve-WS навигацией по индекс-пути элементов (структура обоих документов идентична)
достаёт точную строку пробелов; Get-LangTextWS восстанавливает её вместо одиночного пробела.
Компилятор не менялся — эмитит content verbatim (esc_xml пробелы не трогает).

Выборка 34 формы с multi-whitespace content: LabelDecoration-потерь 0, match 30/34 (остаток —
др. контексты <v8:content> под Attribute + несвязанные кластеры), регрессий 0. Валидация
раундтрипом (decompiler-only, кейс не нужен).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:39:42 +03:00
Nick Shirokov 03b2b5a64e feat(form-decompile,form-compile): ToolTip companion ExtendedTooltip (подсказка расширенной подсказки)
Companion <ExtendedTooltip> (это LabelDecoration) может нести собственный <ToolTip> —
реальный текст подсказки (ML), а не пустой Title. Декомпилятор ловил Title/layout/flags/
events компаньона, но НЕ его ToolTip → реальный двуязычный текст молча терялся (форма
ВводОстатков/ФормаТовары: расширенная подсказка ЕдиницаИзмеренияТНВЭД).

DSL: extendedTooltip.tooltip (ML-текст). Декомпилятор: захват <lf:ToolTip> компаньона
(Get-LangText). Компилятор (ps1+py): tooltip в companionStructKeys + эмит <ToolTip> после
Title (порядок схемы LabelDecoration). ≠ элементного tooltip обычной подсказки —
скоупится вложенностью (могут сосуществовать).

Редкое (1 форма в rt-iter), но реальная потеря контента. Форма ВводОстатков → match.
Кейс input-fields (ОбычноеПоле: элементный tooltip + extendedTooltip с text+tooltip+events)
сертифицирован загрузкой в 1С — оба tooltip сосуществуют. Регресс 43/43, ps1==py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:26:15 +03:00
Nick Shirokov abcd5be2b0 feat(form-decompile,form-compile): presentation элемента CA/фильтра — сохранение формы xs:string vs LocalStringType
Топ-кластер нового baseline (~190 impact). <dcsset:presentation> элемента условного
оформления и групп/сравнений фильтра: платформа хранит ru-only текст и как xs:string
(плоский), и как LocalStringType (мультиязык-обёртка с одним ru). Декомпилятор схлопывал
ru-only LocalStringType в строку (Get-MLText) → компилятор писал xs:string → mismatch.
Плюс компилятор-баг: filter-item presentation эмитился через Emit-MLText (всегда мультиязык
БЕЗ xsi:type), даже для плоской строки.

Фикс:
- Декомпилятор: Get-PresByType — ветвь по xsi:type, сохраняет {lang:text} объект для
  LocalStringType (даже один ru) vs плоскую строку для xs:string. Применён к presentation
  элемента CA (Build-ConditionalAppearance) и фильтра (group + comparison, Build-FilterItem).
- Компилятор (ps1+py): filter-item presentation через by-form Emit-USPresentation/
  emit_us_presentation (строка→xs:string, объект→LocalStringType с xsi:type). CA-item
  presentation компилятор уже эмитил by-form — не трогаем.

Выборка 45 форм с LocalStringType-presentation: presentation-потерь 0, match 27→33,
TOTAL 127→63, регрессий 0 (сверка с baseline). Кейс dynamic-list-form (+CA presentation
{ru} ru-only + filter presentation объект/строка) сертифицирован загрузкой в 1С. Регресс
43/43, ps1==py (общий снэпшот на обоих рантаймах).

baseline после кластера ListSettings/DataSet (A+B+C): match 1869→1975, TOTAL 3495→2557.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:55:00 +03:00
Nick Shirokov aa39973ae6 feat(form-decompile,form-compile): поле DataSet динсписка — useRestriction/attributeUseRestriction/inputParameters (остаток кластера C)
Обычное поле набора <Field DataSetFieldField> может нести ограничения использования и
связь по параметрам выбора:
- <dcssch:useRestriction> {field?,condition?,group?,order?} (54 формы) — где поле НЕ
  использовать (отбор/группировка/порядок/как поле);
- <dcssch:attributeUseRestriction> та же структура (18) — ограничения для реквизитов поля;
- <dcssch:inputParameters> (6) — связь по параметрам выбора (как у параметра дин-списка).

DSL: settings.fields[].useRestriction / attributeUseRestriction (объект {field,condition,
group,order} bool | флаг-строка "#noField #noFilter #noGroup #noOrder" | массив) +
inputParameters. Общие хелперы Get-RestrictList/Emit-RestrictBlock (ps1) и parse_restrict/
emit_restrict_block (py); inputParameters переиспользует Emit-DLInputParameters. Декомпилятор
Build-RestrictObj + Build-DLInputParameters.

Порядок детей поля (из корпус-сигнатур, подтверждён загрузкой в 1С): dataPath, field,
title, useRestriction, attributeUseRestriction, presentationExpression, valueType,
appearance, inputParameters.

Выборка 69 форм с field-props: field-property потерь 0 (match 36→39, TOTAL 396→150,
cascade LOST 111→12). Кейс dynamic-list-form (+useRestriction/attributeUseRestriction
на поле Code) сертифицирован загрузкой в 1С. Регресс 43/43, ps1==py байт-в-байт.

Кластер C (DataSet динсписка) закрыт: Field valueType + CalculatedField + field
presentationExpression/appearance + field useRestriction/attributeUseRestriction/
inputParameters. Остаток (BACKLOG): нюансы пустого value (xs:string vs nil;
LocalStringType self-closing) + отдельный InputField multiple-value кластер.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:15:32 +03:00
Nick Shirokov dab122c166 feat(form-decompile,form-compile): свойства поля DataSet динсписка — presentationExpression + appearance
Обычное поле набора <Field DataSetFieldField> может нести:
- <dcssch:presentationExpression> (выражение представления поля, 30 форм) — строка;
- <dcssch:appearance> (формат/оформление поля, ~9 форм) — dcscor:item SettingsParameterValue
  (тот же блок, что в условном оформлении: параметр→значение с типизацией).

DSL: settings.fields[].presentationExpression (строка) + fields[].appearance (объект
{параметр:значение}). Декомпилятор: захват presentationExpression + appearance через
существующий Get-SettingsAppearance. Компилятор (ps1+py): presentationExpression перед
valueType, appearance после valueType (порядок исходника, подтверждён корпус-сигнатурами);
appearance переиспользует Emit-AppearanceValue/emit_appearance_value.

Выборка 36 форм с field pres/appearance: match 33/36, 0 потерь pres/appearance (остаток
3 формы — несвязанные нюансы пустого value параметра / пустого LocalStringType). Кейс
dynamic-list-form (+явное поле Code с presentationExpression+appearance Формат/ЦветТекста)
сертифицирован загрузкой в 1С. Регресс 43/43, ps1==py (общий снэпшот на обоих рантаймах).

Остаток field-свойств (BACKLOG): useRestriction/attributeUseRestriction/inputParameters/
order на обычном <Field> + 2 раскрытых нюанса (пустой xs:string value vs nil; пустой
LocalStringType self-closing vs пара).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:52:19 +03:00
Nick Shirokov 73b07a6fbc feat(form-decompile,form-compile): вычисляемые поля + valueType поля DataSet динсписка (кластер C)
Две части пробела DataSet динамического списка:

1. Field valueType (63 формы/54). Поле набора <Field DataSetFieldField> может нести
   <dcssch:valueType> (тип значения; кастомные/вычисляемые поля). Декомпилятор ловил
   field/dataPath/title/nested, теперь и valueType (переиспользует Decompile-Type);
   компилятор эмитит после title через существующий emit_dl_value_type. 0 потерь на выборке.

2. CalculatedField (6 форм, редкое). Новый ключ settings.calculatedFields — зеркало skd:
   shorthand "Имя [Заголовок]: тип = Выражение #noField #noFilter #noGroup #noOrder"
   (порт Parse-CalcShorthand) или объект. Форм-специфика: dcssch:-теги, presentationExpression,
   orderExpression* (структура {expression,orderType,autoOrder} в namespace dcscommon с
   локальным xmlns), useRestriction{field,condition,group,order}. Эмиттер форм-специфичный
   (skd использует dcscom:-префикс и не имеет pres/orderExpression). Позиция в DataSet —
   после Field*, до Parameter*. Декомпилятор Build-CalcField (объектная форма для точного
   round-trip). Выборка calc-форм: calc-теги ушли из диффов (3/6 match, остаток — отдельный
   field appearance/presentationExpression).

Кейс dynamic-list-form (+grouping +calculatedFields: shorthand с флагами + объект с
presentationExpression/orderExpression/valueType) сертифицирован загрузкой в 1С (порядок
детей CalculatedField подтверждён платформой). Регресс 43/43 (ps1+py).

ps1==py байт-в-байт (сверено на кейсе). Фикс по пути: ps1 Parse-CalcShorthand — `\b` в
generator-heredoc превратился в backspace 0x08 → #-флаги не парсились (py был верен);
поймано прямой сверкой вывода ps1 vs py.

C-остаток (в BACKLOG): свойства обычного <Field> — presentationExpression + appearance
(формат/цвет поля) — отдельный подкластер.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:38:14 +03:00
Nick Shirokov 43d36119cb feat(form-decompile,form-compile): группировка строк динамического списка (StructureItemGroup в ListSettings, кластер B)
Структура группировок дин-списка (`<dcsset:item StructureItemGroup>` → groupItems →
GroupItemField, вложенность через дочерний item) — переиспользована модель/реализация
из skd (Emit-GroupItems/Get-GroupFields), но плоская: группировка списка всегда
линейная цепочка одно-польных уровней над неявными деталями (без children/selection/
order/details — корпус подтверждает).

DSL — новый ключ `settings.grouping` (forgiving-синонимы `structure`/`группировка`):
- шорткат "A > B > C" (вложенные уровни, внешний→внутренний) или массив;
- элемент уровня — строка (имя поля) или объект {field, groupType?, periodAdditionType?,
  periodAdditionBegin?, periodAdditionEnd?} для нестандартного поля (ключи = теги
  исходника; periodAddition с авто-детектом ISO-дата/dcscor:Field).
Корпус 8.3.24 (29 форм/34 уровня): groupType Items 33 / Hierarchy 1, periodAddition нет.

Компилятор (ps1+py): Emit-ListGrouping + рекурсивная цепочка StructureItemGroup в
позиции после conditionalAppearance, до itemsViewMode. Оба пути — shape-дескриптор
(round-trip) и канонический (авторинг). Декомпилятор: Build-ListGrouping (линейная
цепочка; bail→$null на ветвлении/мультиполе/доп.содержимом = честный LOST, не порча);
Get-ListSettingsShape распознаёт `item`→`structure` (раньше → $null/канон-fallback,
из-за чего терялась группировка и додумывался itemsUserSettingID).

Выборка 17 форм с группировкой: match 0→10, TOTAL 75→25 (остаток — др. кластеры:
presentation xs:string, order-item use). Широкая (cat-a 102): match 82→84, TOTAL
280→256, ноль регрессий. Кейс dynamic-list-form (+grouping "Description > Code")
сертифицирован загрузкой в 1С. Регресс 43/43 (ps1+py).

Фикс по пути: PS-ловушка — одноэлементный массив разворачивался при return из
Parse-ListGrouping → строка → индексация давала char → пустой <field>. Unary comma ,@().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:09:48 +03:00
Nick Shirokov 081d3a8a2f feat(form-decompile,form-compile): динсписок DataSet/ListSettings — TypeId-verbatim + DataSetFieldNestedDataSet + ListSettings self-closing (механика кластера A)
Три механических фикса доминирующего кластера встроенных DCS-настроек динсписка
(152/690 дифф-форм rt-iter). Таргет-выборка 102 формы: TOTAL 657→280, match 0→82,
ноль регрессий.

1. TypeId-verbatim. Тип параметра/реквизита, заданный глобальным стабильным GUID
   (<v8:TypeId>, не <v8:Type>) — платформа так сериализует типы, чьё имя в контексте
   недоступно (определяемые/характеристики). Декомпилятор не ловил → параметр терял
   valueType. Маркер 'typeid:<GUID>' в грамматике типа: Decompile-Type ловит <v8:TypeId>,
   Emit-SingleType разворачивает обратно (как роль-по-GUID; GUID глобально стабилен →
   безопасно). Форма ОстаткиПартийСАТУРН/ФормаОстатков → match.

2. DataSetFieldNestedDataSet. Компилятор хардкодил xsi:type="DataSetFieldField" для
   всех полей набора → терял поле-вложенный набор (реквизит табличной части объекта).
   Маркер fields[].nested: декомпилятор ловит ...NestedDataSet, компилятор зеркалит.

3. ListSettings self-closing. Пустой дескриптор listSettings:{} эмитился парой
   <ListSettings></ListSettings>, оригинал — self-closing <ListSettings/> (70 форм
   корпуса). Зеркалим self-closing при пустом эмите (отслеживание буфера).

Остаток кластера (вне A, отдельные задачи): структура группировок списка
(dcsset:item StructureItemGroup), KeyField/CalculatedField/Field-valueType DataSet.

spec: fields.nested, listSettings:{} → self-closing, тип-токен typeid:<GUID>
(раундтрип, не для авторинга). Регресс form-compile 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:16:43 +03:00
Nick Shirokov c383cc4ffe feat(form-decompile,form-compile): батч скаляров — форм. ChildItemsWidth/VerticalAlign/HorizontalSpacing + check/radio EqualItemsWidth/ItemTitleHeight
Раундтрип терял несколько pass-through скаляров:
- форм-уровень <ChildItemsWidth> (36 форм), <VerticalAlign> (26), <HorizontalSpacing> (25) —
  обрабатывались только как элементные генерики, не на форм-уровне;
- <EqualItemsWidth> (check/radio, 28: false 23/true 5) и <ItemTitleHeight> (radio) — чистый
  двусторонний пробел (не ловились вовсе).

decompile: форм-уровневые → KNOWN_FORM_PROPS; элементные → GENERIC_SCALARS (зеркало компилятора).
compile (ps1+py): форм-уровневые через generic Emit-Properties (авто-PascalCase); элементные —
в genericScalars (Emit-Layout→Emit-GenericScalars, покрывает check/radio).

Верификация: таргет-раундтрип 129 форм с этими тегами → 5 категорий закрыты (0 LOST; остаток —
др. категории Button>Parameter/valueType/content). Регресс form-compile 43/43 (ps1+py); 1С-cert
groups (форм-уровень) + radio-auto-enum (element). spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:21:59 +03:00
Nick Shirokov 01e5de8acf feat(form-decompile,form-compile): userSettingPresentation — плоская строка xs:string vs мультиязычный LocalStringType
Раундтрип ломал кастомную подпись пользовательской настройки в элементах настроек компоновки
(filter/order/conditionalAppearance/dataParameters): <dcsset:userSettingPresentation xsi:type="xs:string">
эмитился как мультиязычный <v8:item> блок (без нужного xsi:type) → 182 строки diff на 13 формах.

Корпус (acc+erp 8.3.24): 26 xs:string (плоская строка) vs 7 v8:LocalStringType (мультиязычный).
Компилятор всегда звал Emit-MLText (мультиязычная форма без xsi:type) — ломал ОБА случая.

compile (ps1+py): выделенный Emit-USPresentation/emit_us_presentation — строка → xsi:type="xs:string",
объект {ru,en} → xsi:type="v8:LocalStringType". Заменены 4 call-site (filter item/CA/dataParameters).
decompile: Get-PresText (строка ИЛИ объект) уже стоял в filter/group/order; добавлен в dataParameters
(был Get-MLText, ронял xs:string).

Верификация: таргет-раундтрип 13 форм с xs:string-подписью → match (182→0); регресс form-compile
43/43 (ps1+py); 1С-cert dynamic-list-form (оба типа подписи — xs:string и LocalStringType — грузятся). spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:42:27 +03:00
Nick Shirokov 8542b45719 fix(form-compile): FixedArray для одноэлементного значения параметра выбора (PS unwrap)
Раундтрип терял FixedArray у choiceParameter со значением-списком из ОДНОГО элемента
(напр. ВводОстатковВнеоборотныхАктивов/ФормаРедактированияСтрокиНМА: 9 из 10 FixedArray
эмитились как скаляр → 45 строк LOST). Корень — классический PowerShell unwrap: Get-ElProp
возвращает 1-элементный массив, но PS разворачивает его на RETURN функции (и при биндинге
параметра), так что $isArray=false → значение эмитилось как одиночный <Value> вместо
<Value xsi:type="v8:FixedArray"> с одним <v8:Value>.

Фикс (только ps1): в Emit-ChoiceParameters значение читается ПРЯМЫМ member/индексер-доступом
(не через Get-ElProp — его return разворачивает), массив-ность вычисляется до биндинга и
передаётся в Emit-ChoiceParamValue явным флагом -isArray (foreach по развёрнутому скаляру = 1
итерация → корректный 1-элементный FixedArray). PY не затронут (Python не разворачивает списки).

Системный артефакт: во многом раздувал кластер app:item>Value в раундтрипе (PS-харнесс).
Верификация: таргет-форма → match (45→0; FixedArray 1→10); регресс form-compile 43/43 (ps1+py);
1С-cert input-fields (1-элементный массив-choiceParameter → FixedArray, грузится).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:26:55 +03:00
Nick Shirokov 819b7fa126 feat(form-decompile,form-compile): dataParameters динсписка + nil-значение схема-параметра (valueListAllowed)
Раундтрип терял две вещи в настройках динамического списка реквизита (форма ВыборПодписантовПечатныхФорм):
1. <dcsset:dataParameters> — значения параметров запроса в ListSettings (список SettingsParameterValue
   {use, parameter, value?}). Не захватывались/не эмитились вовсе.
2. <dcssch:value xsi:nil/> у схема-параметра при valueListAllowed=true: компилятор по умолчанию его
   пропускал (if valueListAllowed → return), но платформа пишет не всегда (корпус 27 с / 47 без).

dataParameters: грамматика портирована из skd-compile (Emit-DataParameters + Parse-DataParamShorthand +
Test/Emit-EmptyValue) — консистентно с СКД (shorthand "Имя @off" / объект). Form-нюанс: значение
опционально (use=false плейсхолдер без value-узла, в отличие от skd-settings). compile эмитит после
filter (XSD-порядок). decompile: Build-FormDataParameters (объект с полным valueType / shorthand).

nil-значение: декомпилятор ставит явный маркер value:null при valueListAllowed+nil-тег; компилятор
эмитит nil при valueListAllowed + явный value (различает absent от null через Has-DLProp/value_explicit).

Корпус: dataParameters в 9 формах (17 items, все use=false, 4 со значениями DesignTimeValue/ent:/decimal).
Верификация: таргет-раундтрип формы → match (22→0); 9 dataParameters-форм — категория закрыта (остаток —
др. категории filter userSettingPresentation/MultipleValue*). Регресс form-compile 43/43 (ps1+py,
PY-паритет снэпшота); 1С-cert dynamic-list-form (vla-nil + dataParameters грузятся). spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:00:19 +03:00
Nick Shirokov b794560492 feat(form-decompile,form-compile): оператор фильтра Like (подобно) + рус. синоним в shorthand
Раундтрип ломал отбор с comparisonType=Like: декомпилятор выдавал сырой токен Like в
short-form ("Поле Like %x%"), а парсер компилятора его не знал → весь текст уходил в поле,
op сбрасывался в Equal, значение терялось (напр. РегламентированноеУведомление.../ФормаСвДобытВалют:
"КодВалют Like %/ %" → поле="КодВалют Like %/ %", потеря Like + %/ %).

Корпус (acc+erp 8.3.24): из 15 comparisonType недоставал только Like (8 шт.) — добавлен Like/NotLike.
По просьбе — рус. синоним оператора: подобно/неподобно (forgiving-ввод, как ПОДОБНО в конфигураторе).

decompile (filterOpMap): Like→like, NotLike→notLike (каноничный токен short-form).
compile (ps1+py): comparisonTypes + Parse-FilterShorthand opPatterns += like/notLike + подобно/неподобно.
PY доведён до регистронезависимости PS (re.IGNORECASE на op-парсинге + CI-лукап comparisonType),
чтобы Like/LIKE/ПОДОБНО резолвились одинаково в обоих портах.

Верификация: таргет-раундтрип 4 форм с Like → match (было 10→0); регресс form-compile 43/43
(ps1+py); 1С-cert dynamic-list-form (фильтры like и подобно → <comparisonType>Like, грузятся). spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:28:57 +03:00
Nick Shirokov 3340d48898 feat(form-decompile,form-compile): формат динамического заголовка группы/страницы (Format при titleDataPath)
Раундтрип терял <Format> на UsualGroup/Page — формат значения пути к данным заголовка
(<TitleDataPath>): мультиязычный формат вида БЛ=; БИ=* / BF=; BT=* (напр. УчетныеЗаписиЭДО/
УчетнаяЗапись — 4 группы/страницы, 40 строк diff с каскадом структурных v8:item-обёрток).

Корпус (acc+erp 8.3.24): <Format>-блоки — LabelField 1784, InputField 1480 (уже обрабатывались),
UsualGroup 13, Page 10 (пробел). Механизм формата уже был у полей (Add-FormatProps / Emit-MLText);
подключён к группе/странице.

decompile: Add-FormatProps в ветках UsualGroup/Page (захват format/editFormat).
compile (ps1+py): Emit-MLText <Format>/<EditFormat> в Emit-Group/Emit-Page (рядом с titleDataPath).

Верификация: таргет-раундтрип 11 форм с group/page Format → категория закрыта (0 Format LOST;
целевая форма match, было 40→0); остаток — др. категория ListSettings + 1 ring3 (SpreadsheetDocument).
Регресс form-compile 43/43 (ps1+py); 1С-cert кейса groups (группа с Format+TitleDataPath грузится). spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:17:26 +03:00
Nick Shirokov cb3dda0d53 feat(form-decompile,form-compile): ролевой доступ колонки реквизита (View/Edit xr-флаг)
Раундтрип терял <View>/<Edit> на колонках реквизита (колонка ValueTable/ValueTree с <Type>):
ролевой доступ вида <Edit><xr:Common>false</xr:Common><xr:Value name="Role.X">true</xr:Value></Edit>
(напр. НастройкиУчетаЗарплаты/ФормаДополнительныхДанных — колонки РайонныйКоэффициент/Ссылка).
Механизм xr-флага уже был у самого реквизита (View/Edit) и userVisible — у колонок не подключён.

decompile (Decompile-AttrColumn): захват view/edit через Decompile-XrFlag (bool | {common,roles}).
compile (Emit-AttrColumn, ps1+py): эмиссия <View>/<Edit> через Emit-XrFlag после FunctionalOptions.
(Колонки идут своим путём Decompile-AttrColumn, не через GENERIC_SCALARS — коллизии ключа edit нет.)

Корпус (acc+erp 8.3.24): из 282591 колонок — 14 с <View>, 21 с <Edit> (редко, но реально).
Верификация: таргет-раундтрип 72 форм с колоночными View/Edit → категория закрыта (0 LOST;
остаток — другие категории content/right). Регресс form-compile 43/43 (ps1+py); 1С-cert кейса
table (колоночные View common-only и Edit с ролью грузятся в платформу). spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:06:19 +03:00
Nick Shirokov e007233c91 feat(form-decompile,form-compile): Table refreshRequest + форм. collapseItemsByImportanceVariant/groupList (pass-through)
Раундтрип терял три скаляра (чистые двусторонние пробелы — не ловились ни декомпилятором,
ни компилятором):
- Table <RefreshRequest> (запрос обновления дин-списка; корпус acc+erp 8.3.24 — 33, всегда PullFromTop)
- форм-уровень <CollapseItemsByImportanceVariant> (сворачивание по важности; 27: DontUse 26/Use 1)
- форм-уровень <GroupList> (ссылка на группу списка, значение 2:<GUID>/имя; 30)

Все три — pass-through (захват «как есть», зеркало платформы). RefreshRequest — в Emit-Table
рядом с currentRowUse; форм-уровневые — через KNOWN_FORM_PROPS (decompile) + generic Emit-Properties
(авто-PascalCase, compile ps1+py).

Верификация: таргет-раундтрип 68 форм с этими тегами → три категории закрыты (0 LOST; остаток —
другие категории valueType/HorizontalSpacing). Регресс form-compile 43/43 (ps1+py); 1С-cert кейса
dynamic-list-form (все три тега, включая GroupList с GUID, грузятся в платформу). spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:43:55 +03:00
Nick Shirokov 569234b448 fix(form-compile): cfg-префикс для голых конфигурационных типов ConstantsSet/ReportObject
Раундтрип терял префикс: оригинал <v8:Type>cfg:ConstantsSet</v8:Type>, regen — голый
<v8:Type>ConstantsSet</v8:Type> (напр. ПанельАдминистрированияБП/НастройкиРегистровУчета,
реквизит НаборКонстант). cfg:-regex компилятора требует точку (ConstantsSet.X), а голая
форма без .Имя уходила в default без префикса.

Корпус (acc+erp 8.3.24) — голые cfg-типы (без точки): DynamicList 5205 (уже обрабатывался),
ConstantsSet 103, ReportObject 10. Блок DynamicList расширен на все три. Дотированные формы
ConstantsSet.X/ReportObject.X по-прежнему ловит общий cfg:-regex. Декомпилятор не трогали —
он уже отдаёт голую форму (Decompile-Type снимает cfg:).

compile (ps1+py). Верификация: таргет-раундтрип формы → match (было 2 → 0); регресс
form-compile 43/43 (ps1+py); 1С-cert кейса attributes-types (реквизиты ConstantsSet/ReportObject
грузятся в платформу). spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:04:54 +03:00
Nick Shirokov d0361561ca fix(form-decompile): восстановление значимого пробела в мультиязычном заголовке (декорация-разделитель)
PreserveWhitespace=false стрипает <v8:content> </v8:content> → "" (неотличимо от суппресса).
Хелпер Get-LangTextWS восстанавливал пробел только для одиночной (ru-only) строки; для
мультиязычной мапы {ru:" ", en:" "} (напр. БольничныйЛист: LabelDecoration «Пробел1» с ru+en
пробелами) оба значения терялись → regen давал <v8:content/> вместо <v8:content> </v8:content>.

Get-LangTextWS расширен на map-случай (восстанавливает " " в каждом языке, где content-узел
есть, но текст пуст — платформа не эмитит пустой content). Get-MLFormattedValue (заголовок
LabelDecoration) зарефакторен на переиспользование Get-LangTextWS вместо собственного inline
single-string восстановления. Компилятор (Emit-MLItems) пробел уже эмитит корректно — правка
только в декомпиляторе.

Версия 1.0x → 0.103 (декомпилятор — draft, продолжаем 0.xx-схему).
Верификация: таргет-роундтрип формы → match (было 4 → 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:57:35 +03:00
Nick Shirokov 75b77caf9b feat(form-decompile,form-compile): InputField availableTypes + typeDomainEnabled (ограничение доступных типов)
Раундтрип терял блок ограничения типов у поля ввода на составном/характеристика-типе:
<TypeDomainEnabled> + <AvailableTypes> (напр. ЯндексМаркетВитринаФулфилмент/РегламентноеЗадание —
поле ИмяПользователя с AvailableTypes=xs:string Length=0 Variable).

Корпус (acc+erp 8.3.24): AvailableTypes встречается в 18 местах, ВСЕ — на InputField (полное
покрытие scope). Содержимое — стандартный 1С type-description (<v8:Type>+qualifiers), тот же
формат, что у реквизитов → переиспользуем существующий механизм типов:
- decompile: Decompile-Type на узле <AvailableTypes> → компактная DSL-строка (одиночная/мультитип "a | b")
- compile (ps1+py): Emit-Type с tag='AvailableTypes' (сам разбирает мультитип); TypeDomainEnabled — bool pass-through

availableTypes — формат типа реквизита (§Типы): одиночный или составной через "|".

Верификация: таргет-раундтрип всех 17 форм с AvailableTypes → 15 match, остаток 6 строк в 2 формах —
ДРУГИЕ категории (ExtendedTooltip/Value), не AvailableTypes; целевая форма match (было 8 → 0).
Регресс form-compile 43/43 (ps1+py); 1С-cert кейса input-fields (поле с мультитип AvailableTypes
грузится в платформу). spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:40:16 +03:00
Nick Shirokov da1d8c9297 feat(form-decompile,form-compile): суппресс-маркер title:"" для команд (подавление авто-вывода заголовка)
Раундтрип додумывал <Title> команды из имени, когда в оригинале заголовка нет
(напр. ФормаРабочегоМеста_320х320: 126 строк ADDED — «Задания выбрать», «Сканирование
далее» и т.п. на ~30 командах-кнопках с одной картинкой).

Корпус (acc+erp 8.3.24): из 92040 команд форм 99.87% имеют <Title> (захват штатный),
лишь 121 (0.13%) — без заголовка. Значит авто-вывод как дефолт верен (помощь модели),
а для редкого хвоста нужен суппресс-маркер — ровно как уже сделано для элементов
(Emit-Title) и формы (autoTitle).

compile (ps1+py): Emit-Commands зеркалит Emit-Title — ключ title есть+непустой → эмитим;
есть+"" → суппресс (не эмитим, не додумываем); отсутствует → авто-вывод из имени.
decompile: нет <Title> у команды → title:"" (иначе компилятор додумает).

Верификация: таргет-раундтрип формы → match (было 126 → 0); регресс form-compile 43/43
(ps1+py); 1С-cert кейса commands (команда с title:"" грузится без <Title>). spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:22:52 +03:00
Nick Shirokov 6377cdfba5 feat(form-decompile,form-compile): Table headerHeight/footerHeight/currentRowUse + форм. conversationsRepresentation (pass-through)
Раундтрип терял 4 свойства (категория Table-скаляры + форм-уровень):
- Table <HeaderHeight>/<FooterHeight> (высота шапки/подвала в строках; ~35/~6 форм)
- Table <CurrentRowUse> (использование текущей строки; ≠ одноимённое свойство команды,
  у которой свой путь захвата/эмиссии). Значения: DontUse/Use/SelectionPresentation/
  SelectionPresentationAndChoice/Choice
- форм-уровень <ConversationsRepresentation> (Auto/Show/DontShow; редкое)

Все три Table-свойства были явно отложены в Emit-Table (комментарий о «строгом Table-XSD»).
Корпусные данные показывают, что 1С эмитит те же теги в РАЗНЫХ позициях у разных форм →
загрузчик толерантен к порядку детей Table (как и существующий компилятор с ранним DataPath).
Размещены pass-through в Emit-Table (height-теги рядом с UseAlternationRowColor, CurrentRowUse
у блока дин-списка); форм-уровень — generic Emit-Properties (авто-PascalCase).

decompile (ps1): захват headerHeight/footerHeight/currentRowUse на Table; ConversationsRepresentation
в KNOWN_FORM_PROPS. compile (ps1+py): эмиссия в emit_table.

Верификация: таргет-раундтрип 4 форм → match (TOTAL diff lines 0); регресс form-compile 43/43
(ps1+py); 1С-cert кейса table (форма с тремя тегами грузится в платформу). spec обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:09:15 +03:00
Nick Shirokov 6e4fdb443a feat(form-decompile,form-compile): батч скаляров (IncompleteChoiceMode/EqualColumnsWidth/ChildrenAlign/ImageScale/Zoomable/Shape/PictureLocation) + форменные ShowCloseButton/HorizontalAlign/ChildrenAlign/ShowTitle + PictureDecoration NonselectedPictureText + ShowTitle factual
Из топа list-iter2 (формы из корпуса):
- Generic-скаляры (input/radio/group/picDecoration/button через Emit-Layout): IncompleteChoiceMode,
  EqualColumnsWidth(bool), ChildrenAlign, ImageScale, Zoomable(bool), Shape, PictureLocation.
- Форменные свойства → KNOWN_FORM_PROPS (декомпилятор) + авто-PascalCase Emit-Properties:
  ShowCloseButton, HorizontalAlign, ChildrenAlign, ShowTitle.
- PictureDecoration NonselectedPictureText (ML, как у picField; после Title).
- ShowTitle factual у UsualGroup/Page/ColumnGroup — раньше ловили/эмитили только false,
  явный <ShowTitle>true> терялся (8989 в корпусе); теперь true/false при наличии.

⚠️ Table HeaderHeight/FooterHeight/CurrentRowUse НЕ добавлены: строгий Table-XSD требует точной
позиции тегов (Representation→…→HeaderHeight→Footer→…→CurrentRowUse→RowFilter), generic-позиция
ломает загрузку (XDTO exception) — отдельная задача по позициям в Emit-Table.

Зеркало py. Выборка 82 формы: 0 корневых утечек батч-тегов (остаток — CFE-форма с потерянным
контейнером расширения, не связано). Кейсы table/radio-tumbler-strings/groups/element-appearance/
input-fields расширены и сертифицированы в 1С (явный ShowTitle=true, форменные props, picDecoration
NPT, button Shape/PictureLocation). Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:09:32 +03:00
Nick Shirokov 330447c95f feat(form-decompile,form-compile): системные перечисления в choiceList + footerDataPath/footerText/editMode на labelField/radio + AdditionalColumns empty self-closing
Четыре находки из rt-iter2 (формы Хозрасчётный/ФормаСчёта, СпецификацииНоменклатуры):

1. **Системные перечисления в choiceList** (ent: namespace, напр. ent:AccountType=ВидСчёта):
   значение несёт xsi:type="ent:AccountType", компилятор эмитил xs:string (терял тип).
   Per-item ключ `valueType` (как у фильтра): декомпилятор сохраняет не-примитивный
   не-DesignTimeRef xsi:type, компилятор эмитит его вместо авто-детекта.
2. **footerDataPath/footerText на LabelField** — были только у InputField, на поле-надписи
   (колонка таблицы) терялись. Добавлены (decompile + compile, позиция по корпусу).
3. **editMode у RadioButtonField** — не ловился/не эмитился (форма ВидСчёта). Добавлен.
4. **Пустая AdditionalColumns** (table-ref без колонок) — компилятор эмитил пустую пару
   <AdditionalColumns table="X"></...>, платформа — self-closing. Фикс: self-closing при
   пустых columns.

Зеркало py. Обе формы → match. Кейсы radio-tumbler-strings (+ent:AccountType +editMode),
picture-field (labelField-колонка +footerDataPath/footerText), additional-columns
(+пустая группа) сертифицированы в 1С. Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:32:04 +03:00
Nick Shirokov bdd38691aa feat(form-compile): роль по GUID в xr-флаге — без префикса Role. (заимствованные/расширение)
Роль в xr-флаге (userVisible/view/edit/use, объектная форма {common,roles}) может
ссылаться по GUID (<xr:Value name="<guid>"> без префикса Role. — заимствованная роль
или расширение), а не по имени. Emit-XrFlag всегда добавлял Role. → Role.<guid>
(форма Сотрудники/ВыплатыУчётЗатрат: 4 ADDED Role.GUID + 4 LOST). Фикс: ключ-роль,
совпадающий с GUID-паттерном, эмитится как есть (без Role.). Декомпилятор GUID и так
сохранял верно (нет префикса для снятия). Зеркало py.

Форма Сотрудники → match. Кейс attributes-types (+edit с ролью по GUID) сертифицирован
в 1С (платформа принимает неизвестную GUID-роль). Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:10:14 +03:00
Nick Shirokov 2ab1b5bc14 feat(form-compile): пустое значение фильтра "_" → self-closing <dcsset:right> (а не литеральный _)
Маркер пустого значения "_" (Get-FilterValue для пустого <dcsset:right xsi:type="X"/>)
эмитился компилятором как литеральный `_` (<dcsset:right xsi:type="dcscor:Field">_</...>)
в объектной форме фильтра (когда форсится valueType/userSettingPresentation). Платформа
хранит self-closing пустой тег (напр. сравнение с незаданным полем dcscor:Field, или
пустой xs:string). Фикс: value=="_" → <dcsset:right xsi:type="$vt"/> (vt из valueType
или xs:string). Зеркало py.

Формы ОтправкиОтчетности/ФормаЭлемента (dcscor:Field) → match; чинит и Новости
(xs:string пустой в object-форме). Кейс input-fields (+CA фильтр с пустым dcscor:Field)
сертифицирован в 1С. Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:02:17 +03:00
Nick Shirokov 9900f8f656 feat(form-decompile,form-compile): события ExtendedTooltip-компаньона (переиспользование механизма событий элемента)
ExtendedTooltip (companion = LabelDecoration) может нести <Events> (напр.
URLProcessing у hyperlink-подсказки — ОбработкаНавигационнойСсылки). Декомпилятор
ловил own-content (layout/оформление/флаги/hyperlink), но НЕ события → весь блок
Events LOST (топ list-iter: ExtendedTooltip>Events 492 impact). Был отложенный хвост
кластера ExtendedTooltip own-content.

Переиспользован механизм событий элемента: декомпилятор Get-Events → etObj['events'];
компилятор Emit-Events (typeKey 'label', после Title) + 'events' в companionStructKeys
(чтобы text+events шёл структурной веткой). Зеркало py. Round-trip формы
НастройкиИнтеграцииМаркетплейс/ФормаЭлемента бит-в-бит (3 ExtendedTooltip с URLProcessing).
Кейс input-fields (extendedTooltip +hyperlink +events URLProcessing) сертифицирован
в 1С. Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:46:20 +03:00
Nick Shirokov 32273d2be8 feat(form-decompile,form-compile): эмиссия оформления Page/Popup + ColumnGroup HeaderPicture + whitespace Title/ToolTip
Декомпилятор ловил эти свойства (Add-CommonProps/Add-Appearance), но эмиттеры
компилятора их не выводили → LOST. Четыре подфикса (по выбору пользователя из топа
list-iter):

1. Page>BackColor/TitleTextColor/TitleFont (193+): Emit-Page не звал Emit-Appearance.
   Добавлен (profile field, после ShowTitle перед компаньоном — порядок корпуса).
2. Popup>TitleTextColor/TitleFont (133/127): Emit-Popup не звал Emit-Appearance. Добавлен.
3. ColumnGroup>HeaderPicture (144): Emit-ColumnGroup не звал Emit-ColumnPics. Добавлен
   (после ShowInHeader/Layout перед оформлением — порядок корпуса).
4. UsualGroup Title/ToolTip с whitespace-контентом: Add-CommonProps читал title/tooltip
   через Get-LangText (PreserveWhitespace=false стрипал <v8:content> </> → "" →
   компилятор подавлял). Новый Get-LangTextWS восстанавливает " " (как Get-MLFormattedValue).
   1-пробельные tooltip'ы теперь матчатся; редкий N-пробельный → косметика числа пробелов.

Зеркало py. Выборка 40 форм с этими категориями: целевые потери 0 (match 21,
остаток — несвязанный хвост). Кейсы pages (+backColor/titleTextColor/titleFont),
column-group (+headerPicture), button-group (popup +titleTextColor/titleFont)
сертифицированы в 1С. Регресс 43/43 (ps1+py).

Раскрыто (отдельно): MultipleValuesBackColor (input) — другой appearance-ключ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:04:09 +03:00
Nick Shirokov c96bcc3566 feat(form-decompile,form-compile): дата в фильтре = StandardBeginningDate Custom (голая ISO-дата как шорткат)
Дата-значение фильтра платформой почти всегда хранится как StandardBeginningDate
Custom, а не xs:dateTime (корпус 8.3.24: 268 SBD-Custom vs 2 xs:dateTime в
dcsset:right). Добавлен естественный шорткат: голая ISO-дата без valueType →
компилятор выводит SBD Custom+date. Работает и в shorthand-строке фильтра
("ДатаЗаказа > 2020-01-01T00:00:00").

Компилятор: Emit-FilterItem ловит дату без valueType → SBD Custom (раньше → xs:dateTime);
Parse-FilterShorthand больше не ставит valueType=xs:dateTime для даты (оставляет
вывод компилятору). Декомпилятор: SBD Custom+date → голая дата без valueType
(компилятор восстановит), именованный вариант → строка+valueType, SED/нетипичное →
объект+valueType. Escape для плоского xs:dateTime — явный valueType. Зеркало py.

Кейс input-fields (+date-фильтр голой датой объектно и shorthand-строкой, +именованный
вариант) сертифицирован в 1С, round-trip подтверждён (голые даты без valueType
возвращаются). Регресс 43/43, SBD-корпус (40 форм) без регрессий (match 28).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:52:59 +03:00
Nick Shirokov 89bb58a101 feat(form-decompile,form-compile): StandardBeginningDate — короткая форма значения (строка-вариант)
Добавлена короткая запись значения фильтра StandardBeginningDate (как в skd для
StandardPeriod): именованный вариант → просто строка "BeginningOfThisDay" (без даты);
Custom → объект {variant:"Custom", date:"…"} (нужна дата). Компилятор Emit-FilterItem
принимает обе формы (строка → variant-only; объект → variant+date); декомпилятор
эмитит строку для именованных вариантов, объект для Custom.

Зеркало py. Кейс input-fields: именованный вариант переведён на короткую строку
(snapshot байт-в-байт = объектной форме). Round-trip: Custom→объект,
BeginningOfThisDay→строка. Регресс 43/43, SBD-корпус (40 форм) без регрессий.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:31:45 +03:00
Nick Shirokov 3ce71d436f feat(form-decompile,form-compile): StandardBeginningDate в значении фильтра — структурно {variant, date?}
Значение фильтра типа v8:StandardBeginningDate (стандартная дата начала) серилизуется
структурно: <v8:variant xsi:type="v8:StandardBeginningDateVariant">Custom</v8:variant>
+ <v8:date>… (Custom несёт дату; именованные варианты — без). Компилятор эмитил
плоскую склейку InnerText (Custom3999-12-31T23:59:59), декомпилятор брал
сцепленный текст. Корпус 8.3.24: 307 случаев (Custom 280 с датой, BeginningOfThisDay
23, …Week 3, …Year 1; StandardEndDate/StandardPeriod как значение фильтра не
встречаются, но обработаны симметрично).

Не «забытый порт» — skd-decompile тоже не структурирует SBD в filter right (только в
dataParameters). DSL: value = {variant, date?} + valueType="v8:StandardBeginningDate".
Декомпилятор Get-FilterValueWithType читает variant/date; компилятор Emit-FilterItem
эмитит структурно (variant xsi:type выводится из valueType). Зеркало py.

Форма УправлениеОбменом (ДатаЗакрытия = SBD, op Equal): SBD-потерь 0 (остаток diff —
несвязанный TitleFont/FooterText). Кейс input-fields (+CA фильтр SBD Custom+date и
именованный вариант) сертифицирован в 1С, round-trip декомпиляции подтверждён.
Регресс 43/43.

ОТДЕЛЬНАЯ НАХОДКА (не в этом коммите): операторы Filled/NotFilled несут
тип-зависимый плейсхолдер <dcsset:right> (пустой xs:string для строк, SBD с дефолт.
датой для дат), который декомпилятор дропает как беззначный — нужен отдельный фикс.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:25:24 +03:00
Nick Shirokov 459d3feb69 feat(form-decompile,form-compile): appearance Текст/Заголовок/Формат — xs:string/Field/typed LocalString (категория картинок→текст оформления)
В значениях параметров оформления (dcsset:appearance) компилятор форсил
мультиязычный LocalStringType по имени ключа (Текст/Заголовок/Формат), теряя
плоскую xs:string и dcscor:Field. Корпус 8.3.24 (Текст/Заголовок/Формат):
xs:string 823, LocalString одноязычный 658, многоязычный 188, dcscor:Field 105.
Контекст (форменный CA vs Settings дин-списка) НЕ детерминирует (целевая форма
СправкаРасчётПостоянныхИВременныхРазниц — форменный CA с xs:string).

Решение (scoped-различие по форме значения, в этом конкретном контексте — не общая
конвенция DSL): голая строка → плоский xs:string (нелокализ. литерал; "" →
самозакрывающийся тег); объект {ru,en} → LocalStringType; объект {field:"путь"} →
dcscor:Field. Декомпилятор перестаёт схлопывать одноязычный LocalString здесь
(всегда объект-карта языков → различим от xs:string).

Заодно пред-существующий баг: LocalString-значение параметра несёт
xsi:type="v8:LocalStringType" на теге dcscor:value (846 случаев) — Emit-MLText
эмитил голый тег; добавлен опц. параметр xsiType (ps+py).

Зеркало py (байт-в-байт). Выборка 57 CA-форм (xs:string/LocalString/Field, вкл.
целевую): appearance-потерь 0, целевая → match (match 26→40). Кейс input-fields
(+CA Текст: плоский/multilang/пустой/field) сертифицирован в 1С. Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:00:00 +03:00
Nick Shirokov b781f97832 feat(form-decompile,form-compile): string(N,fixed) — AllowedLength=Fixed для строк фикс. длины
Колонки/реквизиты строк фиксированной длины (ИНН/КПП/коды) несут
<v8:AllowedLength>Fixed</v8:AllowedLength>, но DSL выражал только Variable:
компилятор хардкодил Variable, декомпилятор не читал AllowedLength → Fixed терялся
(форма ЭлектроннаяТранспортнаяНакладная/ТитулПеревозчика*: 3 LOST Fixed + 3 ADDED
Variable — мультимножественный учёт тех же колонок).

Корпус 8.3.24: AllowedLength ВСЕГДА присутствует в StringQualifiers (Variable
443127 + Fixed 2687, ABSENT=0) → always-эмиссия Variable верна. Fixed (2687)
всегда с длиной > 0 (12/10/3/1/36…); при Length=0 — всегда Variable.

Грамматика `string(N,fixed)` (по аналогии с `decimal(D,F,nonneg)`). Variable —
дефолт (опускаем суффикс); `variable` принимается forgiving. Emit-SingleType
(ps1+py) эмитит Fixed при суффиксе; декомпилятор Decompile-Type читает AllowedLength
(Fixed → суффикс, Variable/Length=0 → плоский string(N)). Общий путь типов
(реквизиты/колонки/valueType/составные).

Выборка 46 форм с Fixed (вкл. указанную): 0 потерь AllowedLength, целевая форма →
match. Round-trip декомпиляции снэпшота: string(12,fixed)/string(9,fixed) читаются
обратно. Кейс attributes-types (+ValueTable с Fixed-колонками ИНН/Код) сертифицирован
в 1С. Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:29:09 +03:00
Nick Shirokov b2eaa9e129 feat(form-decompile,form-compile): InputField MultiLine факт. значение + Shortcut как generic-скаляр
Две находки из корпусного хвоста (свериться помог category-forms.py против rt-24):

- **MultiLine факт. значение** (190 LOST в выборке rt-24): компилятор эмитил и
  декомпилятор ловил только `true`, терялся явный `<MultiLine>false>` (425 в корпусе
  8.3.24; absent 204694, true 5183). Теперь захват/эмиссия true/false при наличии,
  отсутствие = дефолт (как PasswordMode). Эвристика autoMaxWidth (multiLineDefault)
  не затронута — продолжает срабатывать только на true.
- **Shortcut → generic-скаляр** (94 LOST на 86 формах): эмитился/ловился только у
  PictureField (инлайн), терялся на InputField (169), UsualGroup (41),
  RadioButtonField (39), Page (22), Table (3), CheckBoxField (1). Перенёс в
  GENERIC_SCALARS (любой элемент через Emit-Layout/Add-GenericScalars), убрал инлайн
  PictureField. Команда — отдельный путь (§7), не трогаю.

Заодно подтверждено покрытие (category-forms против rt-24 = 0): ControlRepresentation
(1464), ShapeRepresentation (877), PasswordMode (689) — уже generic/факт. значение.
AllowedLength (625) — это cascade типов полей дин-списка (отдельный кластер, не трогаем).

Зеркало py (байт-в-байт). Выборка 73 формы (MultiLine=false + Shortcut на 6 типах
элементов): 0 потерь. Кейсы input-fields (+multiLine:false +shortcut на input),
groups (+shortcut на group), picture-field (shortcut через generic) сертифицированы
в 1С. Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:47:20 +03:00
Nick Shirokov c4f600a36b feat(form-decompile,form-compile): WarningOnEdit на check/radio/labelField (не только input)
`<WarningOnEdit>` (мультиязычный текст предупреждения при редактировании)
встречается на InputField (576), CheckBoxField (119), RadioButtonField (54),
LabelField (1) по корпусу 8.3.24, но компилятор эмитил и декомпилятор ловил его
только у InputField → терялся на check/radio/labelField.

Расширил эмиссию (Emit-Check/Emit-Radio/Emit-LabelField, после Emit-Layout перед
Format) + захват в декомпиляторе (инлайн SelectSingleNode+Get-LangText в трёх
обработчиках, как у InputField). Парный enum `warningOnEditRepresentation`
(Show/DontShow) уже был generic-скаляром на любом поле — не трогаю. 1С толерантна
к позиции тега внутри поля (сертифицировано загрузкой).

Зеркало py (байт-в-байт). Выборка 46 форм с WarningOnEdit на check/radio:
0 потерь WarningOnEdit. Кейсы input-fields (+check multilang, +labelField) и
radio-tumbler-strings (+radio) сертифицированы в 1С. Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:04:15 +03:00
Nick Shirokov 606ac10fdb feat(form-decompile,form-compile): встроенные картинки (xr:Abs) + TransparentPixel + Page Picture (категория картинок)
Закрыты картиночные потери из ROOT-секции корпуса: Page>Picture (702),
PictureField>ValuesPicture (abs), Button>Picture (abs), а также RowsPicture
и командные/popup-картинки. ColumnGroup>HeaderPicture уже ловился (Add-CommonProps),
бэклог-числа были от старого прогона v0.62.

Две системные дыры + один полный пробел (по корпусу 36707 форм):
- xr:Abs (встроенная картинка) игнорировался везде, кроме PictureDecoration:
  Get-PictureRef и Button/Popup/Command picture брали только <xr:Ref>. Теперь
  src с префиксом "abs:" → <xr:Abs> (как у PictureDecoration). ~358 ValuesPicture
  + ~153 Button + хвост.
- xr:TransparentPixel ловился только у PictureDecoration. Теперь — в объектной
  форме картинки-ссылки {src, loadTransparent?, transparentPixel:{x,y}} у
  ValuesPicture/HeaderPicture/FooterPicture/RowsPicture/Page и у командных картинок.
- Page>Picture не поддерживался ни компилятором, ни декомпилятором. Новый ключ
  picture на Page (конвенция ValuesPicture: дефолт LoadTransparent=false, по корпусу
  416/286). Позиция XSD: после Title/ToolTip/флагов, перед Group/ShowTitle.

Компилятор: Emit-PictureRef + Emit-CommandPicture расширены abs/transparentPixel;
Emit-Page эмитит picture; RowsPicture переведён на Emit-PictureRef (был кастомный
блок без abs/TP). Декомпилятор: Get-PictureRef ловит xr:Abs/TransparentPixel;
новый хелпер Set-CommandPicture (Button/Popup/Command — abs + TP через объектную
форму при наличии TP, иначе скаляр); Page и RowsPicture через Get-PictureRef.
Зеркало py (байт-в-байт). abs валидируется раундтрипом на корпусе (нужен встроенный
бинарь — в синтет-кейсы не кладём); transparentPixel + Page picture сертифицированы
загрузкой в 1С (кейсы picture-field/pages/commands). Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:35:13 +03:00
Nick Shirokov 73f1e6ec26 docs(form-dsl-spec): group — авторский набор значений vs раундтрип-forgiving
По замечанию: код forgiving принимает широкий набор ориентаций (нужно для
раундтрипа — Horizontal/AlwaysVertical встречаются в корпусе), но для АВТОРИНГА
spec должен предлагать только доступные в конфигураторе значения, иначе модель
разметит форму недоступным значением. Помечены явно: страница/группа —
vertical/horizontalIfPossible/alwaysHorizontal; horizontal/alwaysVertical —
только раундтрип-совместимость, не для авторинга.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:54:59 +03:00
Nick Shirokov a3395d4abe feat(form-decompile,form-compile): HorizontalIfPossible в ориентации страницы/группы
Страница (Page) и обычная группа (UsualGroup) теряли <Group>HorizontalIfPossible
</Group> — orientation-карта содержала Horizontal/Vertical/AlwaysHorizontal/
AlwaysVertical, но не HorizontalIfPossible (ROOT Page>Group 359 на 189 формах).

Доступные значения (по конфигуратору + корпусу): страница/обычная группа —
Vertical/HorizontalIfPossible/AlwaysHorizontal (+ Horizontal реально встречается:
1288 форм на странице — XML-enum шире UI-дропдауна, оставлен forgiving); группа
колонок таблицы — Vertical/Horizontal/InCell (уже обрабатывалось, не трогаем).
InCell на странице/группе не добавляем — в корпусе не встречается. Коэрция не
делается: фактическое значение сохраняется как есть (верность раундтрипа).

Добавлен horizontalIfPossible в Emit-Page + Emit-Group switch (ps1+py) и в gmap
декомпилятора (Page + UsualGroup). Таргет-верификация (выборка 50 из 189): 0
остатка, 29 стали match, 0 регрессов. Кейс pages пере-сертифицирован в 1С
(HorizontalIfPossible грузится). Регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:49:45 +03:00
Nick Shirokov dd9af25e67 feat(form-decompile,form-compile): AutoSaveUserSettings дин-списка (settings.autoSaveUserSettings)
Свойство <AutoSaveUserSettings> внутри <Settings xsi:type="DynamicList"> (после
MainTable) — авто-сохранение пользовательских настроек дин-списка. В корпусе
только false (дефолт true → платформа эмитит отклонение). Не обрабатывалось →
терялось (ROOT Attribute>AutoSaveUserSettings 292 на 178 формах).

Ключ settings.autoSaveUserSettings (bool); декомпилятор захватывает факт. значение,
компилятор эмитит после MainTable при наличии ключа. Зеркало py.

Таргет-верификация (выборка 50 из 178): 0 остатка, 27 стали match, 0 регрессов.
Кейс dynamic-list-form расширен (+autoSaveUserSettings), сертифицирован в 1С.
Регресс 43/43 (ps1+py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:36:27 +03:00
Nick Shirokov 6d9c41507c docs(form-dsl-spec): excludedCommands — общее свойство поля (не только таблица)
Дополнение к фиксу CommandSet: ключ excludedCommands теперь работает на любом
поле (input/label/check/spreadsheet/html/formatted/picField + таблица + форма),
а не только на таблице. Уточнил описание и значения по типам.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:42:52 +03:00
Nick Shirokov d8689b3674 feat(form-decompile,form-compile): CommandSet (отключённые команды) — общее свойство поля
Ранее excludedCommands обрабатывался только для Table-элемента и форм-уровня.
Обычные поля (InputField/LabelField/CheckBoxField/SpreadSheetDocumentField/HTML/
Formatted/Picture) идут через Emit-SimpleField и др. — CommandSet там терялся
(кластер SpreadSheetDocumentField>CommandSet, baseline impact ~1443).

Централизовал: захват в Add-CommonProps (декомпилятор, общий для всех полей),
эмит в Emit-Layout (компилятор ps1+py), убрал дубль из Table-эмиттера. CommandSet —
дочерний элемент базового FormField в схеме, позиция фиксирована независимо от
подтипа → ранняя (после TitleLocation, перед скалярами/Height), как у spreadsheet.

Таргет-верификация (новый цикл category-forms.py): 43 формы корпуса с CommandSet →
после фикса 0 остатка (CommandSet + ExcludedCommand cascade), 26 стали match.
Кейс table пере-сертифицирован в 1С (ранняя позиция грузится), ps1==py, регресс 43/43.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:41:43 +03:00
Nick Shirokov d5d19710cb feat(form-decompile,form-compile): корпусный хвост — 8 generic-скаляров + PasswordMode факт. значение
Из ROOT-секции корпусного прогона 8.3.24 (rt-24): дешёвые широкие generic-скаляры
(pass-through, обе стороны + py), не обрабатывались ранее:
- ControlRepresentation (свёртка группы, 1464) · ShapeRepresentation (форма кнопки/
  попапа, 1023) · AutoAddIncomplete (516) · MarkNegatives (433) · InitialListView
  (нач. позиция списка, 246) · ChoiceListHeight (143) · ThreeState (флажок, 119) ·
  ScrollOnCompress (прокрутка страницы, 104).

PasswordMode на InputField: эмитился только при true → терялись 349/504 явных false
в корпусе (ROOT 689). Теперь факт. значение (как на LabelField/markIncomplete);
декомпилятор захватывает true/false при наличии тега.

Проверка: 15 корпусных форм с этими тегами — 0 расхождений по тегам. Регресс 43/43
(ps1+py). Совокупный ожидаемый impact ~4700 TOTAL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:42:07 +03:00
Nick Shirokov dd226697bf feat(form-decompile,form-compile): GanttChart design-time Settings из ring-3 (Фаза 3 кластера Chart-Settings)
GanttChart Settings (<Settings xsi:type="d4p1:GanttChart">) = вложенный
<d4p1:chart> (полный Chart-блок) + gantt-специфика (points/series/timeScale/
drawEmpty/…) — ТОТ ЖЕ d4p1-неймспейс, что и Chart. Переиспользован генерик-движок
Chart как есть (рекурсивный захват/эмит).

Изменения: параметризован xsi:type Settings (выводится из типа реквизита:
d5p1:GanttChart → d4p1:GanttChart); elseif декомпилятора и guard точек/осей
расширены на d4p1:(Gantt)?Chart. Зеркало py (ctype-параметр).

Все 16 форм Ганта корпуса 8.3.24 — раундтрип БАЙТ-В-БАЙТ (0 diff, 0 ring3).
Кейс chart-gantt-settings (полная диаграмма Ганта из эталона) сертифицирован
загрузкой в 1С. Регресс 43/43 (ps1+py).

Кластер Chart-Settings закрыт (Planner + Chart + GanttChart). Остаток —
только редкие диаграммы с точками/осями (типизир. значения/d4p1-ML) → честный
fail-ring3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:42:54 +03:00
Nick Shirokov 1333e09ff8 feat(form-decompile,form-compile): Chart design-time Settings из ring-3 (Фаза 2 кластера Chart-Settings)
Реквизит-диаграмма несёт <Settings xsi:type="d4p1:Chart"> — встроенный конфиг
(~110-130 версионно-вариативных полей: тип/серии/легенда/заголовок/шкалы/цвета/
оси, глубокая вложенность с повторяющимися именами). Корпус 8.3.24: 5 форм.

Подход (с пользователем): ГЕНЕРИК-движок. Ключ chart на реквизите; рекурсивный
захват/эмит поддерева d4p1, ключи = локальные имена тегов, порядок ключей =
порядок эмиссии → раундтрип ЛЮБОЙ версии/набора полей бит-в-бит (платформа
добавляет поля, не переставляет). Структуры распознаются по форме узла
(line {width,gap,style} / border {width,style} / font {kind} / ML / области
{left,right,top,bottom} / серии-массивы); малые name-set'ы: ML-поля, серии,
attrs-узлы (gaugeQualityBands). Расширяемость: любое из ~127 свойств — по
каноничному имени.

Авторинг с нуля: декомпиль рабочей диаграммы как шаблон + правка ядра
(chartType/серии/легенда/цвета). Default-fill через merge НЕ делаем — конфликт
с байт-точностью неполных форм (см. docs/form-dsl-spec.md).

Результат: 4 из 5 форм корпуса — байт-в-байт (включая версионно-вариативные).
5-я (точки/оси realPointData/realDataItems с типизир. значениями xsi:type,
xsi:nil, ML с префиксом d4p1:) → честный fail-ring3 (редкий вариант, не
поддержан генериком). Снят fail-ring3 для d4p1:Chart (GanttChart — Фаза 3).
Заодно фикс: d5p1:Dendrogram отсутствовал в specialTypeNs (ps1+py).

Декомпилятор ps1-only (генерик-рекурсия); компилятор зеркало py (ps1==py
байт-в-байт). Кейс chart-settings (полная диаграмма из эталона
ПроверкаКонтрагента) сертифицирован загрузкой в 1С. Регресс 42/42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:01:15 +03:00
Nick Shirokov f534add7b4 feat(form-decompile,form-compile): измерения планировщика (dimensions) — Planner Phase 1
Расширение Phase 1 кластера Chart-Settings: реквизит pl:Planner теперь несёт
измерения планировщика (<pl:dimension> — «Измерения» в конфигураторе) с элементами.

DSL planner.dimensions[]: объект разреза (value — ссылка xr:DesignTimeRef или nil,
text-заголовок, цвета, font) + elements[] (элементы измерения, РЕКУРСИВНЫ — могут
нести вложенные elements, как показывает UI колонкой «Элементы»; поле
showOnlySubordinatesAreas). Тип value авто-выводится: ссылочный вид →
xsi:type="xr:DesignTimeRef", иначе xs:string. Пустой текст → самозакрывающийся
<pl:text/> (как в выгрузке). Общие хелперы Emit/Get-PlannerValue/Text применены
и к элементам расписания (items).

Раундтрип бит-в-бит: синтетика upload/epf/Диаграммы (items + 2 dimensions +
вложенные elements + period). Зеркало py (ps1==py байт-в-байт). Кейс chart-fields
расширен измерением (nil-разрез + xs:string-элемент + showOnlySubordinatesAreas),
сертифицирован загрузкой в 1С. Регресс 41/41 (ps1+py).

Ограничение: item.dimensionValues (привязка элемента расписания к элементам
измерений) пока всегда пустой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:57:54 +03:00
Nick Shirokov f064d53eb2 feat(form-decompile,form-compile): Planner design-time Settings из ring-3 (Фаза 1 кластера Chart-Settings)
Реквизит planner-типа несёт <Settings xsi:type="pl:Planner"> — встроенный конфиг
поля-планировщика (элементы расписания + оформление/поведение + шкала времени).
Раньше декомпилятор делал fail-ring3 (третий вид Settings после TypeDescription/
DynamicList). Корпус 8.3.24: Planner Settings = 1 реальная форма (КонтактныеЛица/
ФормаЛиды), всё chart-семейство = 38 форм. Решение (с пользователем): структурный
DSL ради возможности модели СОЗДАВАТЬ дашборды/планировщики, не только раундтрипить.

DSL: ключ planner:{…} на реквизите (docs/form-dsl-spec.md):
- items[] (элементы расписания) + appearance/поведение-скаляры + timeScale
  (placement/levels[]/colors) + period;
- цвета verbatim, шрифт {kind:AutoFont}/ref, граница {width,style}, ML-форматы;
- компилятор подставляет дефолты для пропущенных ключей (краткий авторинг),
  декомпилятор — полный захват (раундтрип бит-в-бит).

Снят fail-ring3 для pl:Planner (Chart/GanttChart остаются — Фазы 2/3).
Заодно фикс: d5p1:Dendrogram отсутствовал в specialTypeNs (эмитился без
xmlns-префикса) — добавлен в карту (ps1+py).

Раундтрип бит-в-бит: синтетика upload/epf/Диаграммы (с items+period) +
реальная ФормаЛиды (без items/period, иные значения скаляров). Зеркало py
(ps1==py байт-в-байт). Кейс chart-fields расширен (+planner +dendrogram),
сертифицирован загрузкой в 1С. Регресс 41/41 (ps1+py).

Ограничение Phase 1: dimensions/item.dimensionValues пока всегда пустые.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:35:10 +03:00
Nick Shirokov 29f288fe1d feat(form-decompile,form-compile): поля диаграмм из ring-3 (Chart/Gantt/GraphicalSchema/Planner/Period/Dendrogram)
Снят fast-fail на 6 chart-полях. Все — простые скелеты (как document/gauge),
кроме GanttChart с вложенной <Table> (ключ ganttTable, переиспользует
Decompile/Emit-Element — устранена коллизия тип-ключа table). Типы реквизитов
уже в special-type ns-карте (d5p1:Chart/GanttChart/FlowchartContextType/
GeographicalSchema, pl:Planner) + v8:StandardPeriod. Edit/WarningOnEditRepresentation
у GraphicalSchema — через готовые GENERIC_SCALARS.

Guard: реквизит с design-time конфигом диаграммы/планировщика
(<Settings xsi:type="d4p1:GanttChart"/"pl:Planner"/…> — третий вид Settings
после TypeDescription/DynamicList) → честный fail-ring3 (не теряем молча).
Planner несёт Settings всегда; Chart/Gantt — при настройке. Поля без Settings
(диаграмма из кода/график-схема/период/дендрограмма/гео) роундтрипятся полностью.

Выборка 2.17: ring3 4→0 (весь ring3 разобран!). Кейс chart-fields
(chart+graphicalSchema+period+ganttChart с ganttTable) сертифицирован в 1С.
Зеркало py байт-в-байт. Регресс 41/41 (ps1+py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:40:48 +03:00
Nick Shirokov 5ebc02d0b3 feat(form-decompile,form-compile): ConditionalAppearance формы из ring-3 (переиспользование DCS-грамматики)
Снят fast-fail на ConditionalAppearance (1304 формы, 4%). Структура — та же
DCS-грамматика, что settings.conditionalAppearance дин-списка, поэтому
переиспользованы Build-ConditionalAppearance (декомпилятор) и
Emit-ConditionalAppearance (компилятор) как есть.

Отличия от настроек списка: тег-обёртка <ConditionalAppearance> (без dcsset:,
параметр wrapTag) + нет блок-мета viewMode/userSettingID + размещение (последний
child <Attributes>, не отдельный Form-child). Форменный ключ conditionalAppearance
(selection/filter/appearance/presentation). Scope в формах не встречается
(0/6186) → fail-ring3 только при scope.

Заодно фикс: мультиязык-presentation элемента CA → xsi:type="v8:LocalStringType"
(был голый <dcsset:presentation>; чинит и settings CA path).

Выборка 2.17: ring3 7→4 (остаток — только chart-семейство), match 211→214,
CA-формы бит-в-бит. Зеркало py байт-в-байт, кейс input-fields
(+conditionalAppearance: selection+filter+appearance+presentation)
сертифицирован загрузкой в 1С. Регресс 40/40 (ps1+py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:08:14 +03:00
Nick Shirokov ca96a7413a fix(form-decompile): whitespace-заголовок декорации-разделителя (пробел в Title)
PreserveWhitespace=false у XmlDocument стрипает значимый пробел в
<v8:content> </v8:content> → декомпилятор читал "" (неотличимо от суппресс-
маркера title:"") → компилятор не эмитил Title → LOST (надписи-разделители
"РазделительТумблеров" и т.п., 487 в корпусе).

Фикс в Get-MLFormattedValue (декорации label/picture): пустой текст ПРИ наличии
узла <v8:content> → исходно был пробел (платформа не эмитит пустой Title) →
восстанавливаем " ". Компилятор уже эмитит пробел корректно (" " truthy в
Emit-DecorationTitle). Decompiler-only.

Выборка 2.17: match 209→211, TOTAL 12→0 (остаток — только GroupList,
документированный не-покрываемый). Валидировано раунтрипом (форма
ЗадачаИсполнителя/ЗадачиПоПредметуБП → match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:47:34 +03:00
Nick Shirokov 2ff99d1b77 fix(form-compile): составной тип в shorthand параметра дин-списка (dcssch:valueType)
Parse-DLParamShorthand брал тип regex'ом (\S+) — один токен без пробелов.
Составной тип (CatalogRef.X | CatalogRef.Y, с пробелами вокруг |) не матчился
→ вся строка уходила в name → компилятор эмитил <dcssch:name>Имя: TYPE | TYPE</…>
и ТЕРЯЛ <dcssch:valueType>.

Фикс: тип = ([^=]+?) (допускает пробелы/|, исключает '='-разделитель значения);
составной резолвится по частям (per-part Resolve-TypeStr, rejoin ' | ').
Emit-DLValueType уже split'ил по |, эмиссия корректна. Зеркало py.

Выборка 2.17: TOTAL 38→12 (составной тип у дин-списков Task/прочих восстановлен).
Кейс dynamic-list-parameters (+составной параметр DocumentRef | CatalogRef → два
TypeSet) сертифицирован загрузкой в 1С. Регресс 40/40 (ps1+py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:42:51 +03:00
Nick Shirokov e16b23968e feat(form-decompile,form-compile): хвост раскрытых CommandInterface-форм (TOTAL 95→38)
Добивка длинного хвоста, раскрытого при выводе CommandInterface из ring-3.

Generic-скаляры (обе стороны,+py): titleDataPath (Page/Group динамический
заголовок, 1723; парный к footerDataPath), extendedEdit (input, 3400),
maxRowsCount/autoMaxRowsCount/heightControlVariant (Table высота),
editTextUpdate (input enum, 739). ML-текст: warningOnEdit (input, 641),
footerText (input, 269), nonselectedPictureText (picField, 265).

Фикс markIncomplete → фактическое значение (AutoMarkIncomplete true/false;
раньше только true, 1170 false терялись на input+table).

Выборка 2.17: TOTAL 95→38, match 197→209, diff 6→4. Остаток (отложено в
BACKLOG): dcssch:valueType/name манглинг типов полей дин-списка (отдельная
подсистема), LabelDecoration title-пробел, GroupList (не покрываем).
Кейсы input-fields/table/pages расширены и сертифицированы в 1С. Регресс 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:25:42 +03:00
Nick Shirokov d7dedd4843 feat(form-decompile,form-compile): CommandInterface — командный интерфейс формы из ring-3
Снят fast-fail на CommandInterface (4388 форм, 13.5% корпуса — крупнейший
оставшийся триггер ring-3).

Форменный ключ commandInterface = панели commandBar + navigationPanel, списки
переопределений авто-расстановки (платформа эмитит только отклонения). Элемент:
command (verbatim; "0"=пустой), type (Auto опускаем/Added), defaultVisible,
visible (тот же xr-flag, что userVisible/use — bool или {common,roles}),
group (CommandGroup verbatim), index, attribute. Порядок тегов Item:
Command,Type,Attribute,CommandGroup,Index,DefaultVisible,Visible.

Две формы записи панели: плоский массив (декомпилятор эмитит её) + древовидная
{группа:[команды]} как входной сахар (алиасы important/goTo/seeAlso→
FormNavigationPanel*, important/createBasedOn→FormCommandBar*; иной ключ verbatim;
group из ключа, элементы не дублируют). Голый элемент → строка-shorthand.
Переиспользует Decompile-XrFlag/Emit-XrFlag.

Выборка 2.17: ring3 37→7, match 181→197; сам CommandInterface роундтрипится
бит-в-бит (0 CI-diff, остаток TOTAL — несвязанный хвост раскрытых форм). Зеркало
py байт-в-байт, кейс commands (+commandInterface tree-форма) сертифицирован
загрузкой в 1С. Регресс 40/40 (ps1+py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:00:22 +03:00
Nick Shirokov ef036c7cf1 feat(form-decompile,form-compile): хвост report-форм 2.17 (TOTAL 23→0)
Добивка длинного хвоста, раскрытого при выводе спец-полей из ring-3.

Generic-скаляры (обе стороны, +py): ItemHeight (radio), DropListWidth (input).
Форменное VariantAppearance → KNOWN_FORM_PROPS. Targeted: PasswordMode на
LabelField (факт. значение, ≠ input if-true), ChoiceButtonPicture (input, через
Emit/Get-PictureRef), TransparentPixel (под-элемент <xr:TransparentPixel x y> в
<Picture> PictureDecoration → ключ transparentPixel:{x,y}, 1162 в корпусе).

Компилятор-баг ChoiceParameters без значения: платформа эмитит
<app:value xsi:nil="true"/> (13 в корпусе), компилятор додумывал пустую
FormChoiceListDesTimeValue. Теперь по наличию ключа value (hashtable shorthand
vs PSCustomObject — для PS; dict — для py).

Выборка 2.17: TOTAL 23→0, match 170→181, diff 13→2 (остаток — только GroupList,
документированный не-покрываемый: декомпилятор намеренно опускает во избежание
тихой порчи ссылки). Регресс 40/40 (ps1+py). Кейс input-fields расширен
(value-less choiceParameter → nil) и сертифицирован загрузкой в 1С.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:59:12 +03:00
Nick Shirokov b5769ce373 feat(form-decompile,form-compile): report-form свойства + 3 элементных скаляра (раскрыто из-под ring-3)
Закрытие спец-полей раскрыло пробелы в формах Report-объектов (доминируют
среди SpreadSheet-форм). 6 форменных скаляров report-формы → KNOWN_FORM_PROPS
(decompiler-only; компилятор уже эмитит через Emit-Properties): ReportResult/
DetailsData (имена реквизитов), ReportFormType (Main/Settings/Variant),
AutoShowState (Auto/DontShow/ShowOnComposition), ReportResultViewMode/
ViewModeApplicationOnSetReportResult (всегда Auto).

+ 3 элементных generic-скаляра (обе стороны, +py): HorizontalSpacing (группа,
3592 в корпусе), RepresentationInContextMenu (кнопка, 1673),
SettingsNamedItemDetailedRepresentation (таблица, 778).

Выборка 2.17: TOTAL 101→23, match 166→170, diff 17→13 (ring3 37, 0 крашей).
Кейсы groups/commands/table расширены и сертифицированы загрузкой в 1С.
Регресс 40/40 (ps1+py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:38:31 +03:00
Nick Shirokov 7905615091 feat(form-decompile,form-compile): спец-поля документ/датчик из ring-3 (SpreadSheet/HTML/Text/Formatted/ProgressBar/TrackBar)
Кластер ring-3: 6 листовых полей-«документ/датчик» больше не валят
декомпилятор в fast-fail. По корпусу SpreadSheetDocumentField — 21% форм
(самый массовый триггер ring-3), обгоняет CommandInterface.

Декомпилятор: +ELEMENT_KEY, общий Decompile-SimpleField (скелет поля) +
Add-GaugeScalars (числовые min/max/шаги без xsi:type — ≠ типизированных
input). Типоспец. enum/bool скаляры — через GENERIC_SCALARS.
enableDrag/enableStartDrag — фактическое значение (платформа эмитит явный
false на SS; заодно покрыло не-дин-список таблицы). Форменные ScalingMode/
VerticalSpacing → KNOWN_FORM_PROPS.

Компилятор(+py байт-в-байт): тип-ключи spreadsheet/html/textDoc/formattedDoc/
progressBar/trackBar + синонимы (XML-имя/рус), общий Emit-SimpleField,
GENERIC_SCALARS (output/protection/scrollbars/viewScalingMode/show*/…).

Спец-типы реквизитов с локальным xmlns на <v8:Type> (mxl:SpreadsheetDocument
7387, fd:FormattedDocument, d5p1:TextDocument/Chart/GanttChart/Flowchart/Geo/
DataAnalysis, pdfdoc:PDFDocument, pl:Planner) — резолв по полному значению
типа (префикс d5p1 неоднозначен).

Выборка 2.17: ring3 61→37 (−24 формы), match 156→166, 0 dec/compile-fail.
Кейс special-fields (все 6 типов + спец-типы) сертифицирован загрузкой в 1С.
Регресс 40/40 (ps1+py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:25:12 +03:00
Nick Shirokov 24e7116b7d fix(form-compile): эмитить пустой <Attributes/> у форм без реквизитов
Компилятор при отсутствии реквизитов не эмитил <Attributes> вовсе (предполагал
толерантность 1С — не проверено). Корпус: 100% форм (17033) имеют <Attributes>,
162 — пустой <Attributes/>, 0 без него. Платформа эмитит ВСЕГДА.

Emit-Attributes теперь эмитит <Attributes/> при пустом списке. Зеркало py. Кейс
minimal (форма только с заголовком) → снапшот +<Attributes/>, сертифицирован
загрузкой в 1С (форма с пустым Attributes грузится). Форма РазблокированиеРеквизитов
→ match. Раундтрип: match 156→157, with diff 3→2 (остаток — 2 GroupList,
осознанно непокрыты).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:15:23 +03:00
Nick Shirokov 1ed23b2a08 feat(form-decompile,form-compile): частичная/минимальная форма ListSettings (дескриптор settings.listSettings)
Раундтрип TOTAL 21→0, match 153→156. Компилятор всегда эмитил ПОЛНЫЙ каноничный
скелет <ListSettings> (filter+order+conditionalAppearance+itemsViewMode+
itemsUserSettingID), а ~7% форм имеют частичный (напр. только <filter> с
userSettingID) → лишние контейнеры = ADDED.

- Декомпилятор: Get-ListSettingsShape фиксирует «форму» скелета в
  settings.listSettings (ordered-карта present top-level: filter/order/
  conditionalAppearance → блок-мета 'v'/'u'/'vu'/''; itemsViewMode/
  itemsUserSettingID → true). Дескриптор пишется ТОЛЬКО для не-каноничных форм
  ($null для полного канона и неподдержанных top-level item/dataParameters/…).
- Компилятор: при наличии дескриптора эмитит ТОЛЬКО указанные части (контент из
  settings.filter/order/CA, блок-мета из дескриптора); иначе — полный канон
  (без изменений). Аддитивно, дескриптор-gated → 93% канон-форм не затронуты.

Зеркало py. Формы ОстаткиАлкогольнойПродукцииЕГАИС (filter-only) и
ОстаткиПартийЗЕРНО (filter+order) → ListSettings бит-в-бит. Регресс 39/39
(канон-путь). Партиал-путь — harness + provenance (подмножество канона).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:51:14 +03:00
Nick Shirokov cc8b283f1b feat(form-decompile,form-compile): DisplayImportance + MinValue/MaxValue + verticalSpacing + rowsPicture LoadTransparent + autoMaxWidth/RowPictureDataPath fixes
Раундтрип TOTAL 25→21, match 146→153. Батч из 6 хвостовых находок:

- DisplayImportance: атрибут открывающего тега ЛЮБОГО элемента (адаптивная
  важность VeryHigh/High/Usual/Low/VeryLow). Хелпер DI-Attr/di_attr внедрён в
  открывающие теги; декомпилятор захватывает в диспетчере (атрибут узла).
- MinValue/MaxValue (input): типизированные (xsi:type). Тип сохраняется через
  тип JSON-значения: число → xs:decimal, строка → xs:string.
- verticalSpacing: generic-скаляр группы (<VerticalSpacing>).
- rowsPicture: объектная форма {src, loadTransparent} — компилятор хардкодил
  LoadTransparent=false, теперь факт. значение (792 false / 327 true в корпусе).
- autoMaxWidth: суппресс multiLineDefault-эвристики (декомпилятор фиксирует факт.
  значение; multiLine-input без тега → autoMaxWidth:true).
- RowPictureDataPath: снят гейт hasMainTable у реинъекции smart-default (дин-список
  без mainTable тоже несёт <RowPictureDataPath>; ""-маркер ловит реальное отсутствие).

Зеркало py (компилятор). Кейсы input-fields/groups расширены и сертифицированы
загрузкой в 1С. Регресс 39/39 в обоих рантаймах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:55:51 +03:00
Nick Shirokov bc81faf892 feat(form-decompile,form-compile): суппресс авто-вывода (MainAttribute/SavedData/AutoTitle) + AutoFillAvailableFields + SaveWindowSettings
Раундтрип TOTAL 40→25, match 138→146. Три класса «компилятор додумывает на
main/titled формах» (декомпилятор не давал суппресс-маркера) + два непокрытых свойства.

- MainAttribute: эвристика 11b.3 (нет явного main + ровно 1 объектный реквизит
  → помечает main). Декомпилятор зеркалит условие → ставит main:false (компилятор
  уже исключает такие кандидаты). Объектные реквизиты часто НЕ main (DynamicList
  1207 без, RecordSet 226 без, и т.д.). Decompiler-only.
- SavedData: эвристика $mainSaved (main + Catalog/Document/ChartOf*/ExchangePlan/
  BusinessProcess/Task Object + RecordManager → SavedData=true). Часто отсутствует
  (DocumentObject 332 без = 23%). Компилятор: явный savedData:false побеждает;
  декомпилятор ставит savedData:false для main-реквизита saved-типа без <SavedData>.
- AutoTitle: компилятор инъектит false при наличии title (~95% форм). Редкие 5%
  (Title есть, AutoTitle нет) → декомпилятор ставит autoTitle:"", компилятор
  пропускает пустую строку в Emit-Properties (общий ""-суппресс).
- AutoFillAvailableFields: свойство <Settings> дин-списка (дефолт true, эмит только
  отклонение false; ключ settings.autoFillAvailableFields).
- SaveWindowSettings: форменный bool (KNOWN_FORM_PROPS + auto-PascalCase).

Зеркало py (компилятор). Кейс dynamic-list-form +saveWindowSettings (сертифицирован).
Формы ЗадачаИсполнителя/Дополнительно, БизнесСеть/*, АнализПравДоступа → чисто.
Регресс 39/39 в обоих рантаймах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:07:38 +03:00
Nick Shirokov 1a601a4d0c docs(form-decompile): GroupList — задокументировано как осознанно непокрытое
Форменное свойство <GroupList>id:uuid</GroupList> ссылается на члена формы по
оригинальному id + непрозрачному uuid. Компилятор раздаёт id заново (глобальный
счётчик по порядку эмиссии) → оригинальный id не воспроизводится; uuid нигде
больше в форме нет. Корректный раундтрип требовал бы имя-резолва с предпроходом
присвоения id (свойство эмитится до реквизитов) — рефактор ради редкого (30 на
корпус) свойства неясной семантики. Verbatim прошёл бы harness, но молча испортил
бы ссылку при загрузке. Решение: честный LOST, документируем ограничение.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:11:42 +03:00
Nick Shirokov 8aad132cc3 feat(form-decompile,form-compile): Command AssociatedTableElementId (используемая таблица)
Свойство команды «Используемая таблица» — ссылка по ИМЕНИ элемента-таблицы
(<AssociatedTableElementId xsi:type="xs:string">Имя</…>, 240 в корпусе, всегда
xs:string). Команда работает в контексте этой таблицы (текущая строка).

- Ключ table; forgiving-синонимы associatedTableElementId (XML-тег) и
  ИспользуемаяТаблица (рус., регистро-/пробело-незав.) — команды идут отдельным
  путём Emit-Commands, не через PROP_SYNONYMS слой Emit-Element.
- CurrentRowUse («использование текущей строки») уже поддерживался.

Зеркало py. Кейс table расширен (table + currentRowUse), сертифицирован
загрузкой в 1С. Регресс 39/39 в обоих рантаймах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:58:04 +03:00
Nick Shirokov fd9539dd20 docs(form-dsl-spec): уточнение Table autofill — вспомогательные таблицы динамического списка
<Autofill>true> у таблицы появляется для вспомогательных таблиц динамического
списка (отборы/параметры/настройки, привязанные к КомпоновщикНастроек), где
состав колонок генерится платформой (ChildItems пуст). В палитре свойств не
показывается — внутренний флаг конструктора. Уточнено по домен-экспертизе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:43:01 +03:00
Nick Shirokov 6807b07681 feat(form-decompile,form-compile): Table + PictureField leaf-скаляры (autofill/multipleChoice/searchOnInput/markIncomplete/hyperlink/shortcut)
Раундтрип TOTAL 61→57, match 130→135. Захват+эмит «как есть»:

- Table: autofill (<Autofill> — СВОЁ свойство таблицы, ≠ AutoCommandBar autofill
  = tableAutofill; редко, 270 в корпусе, всегда true = автогенерация колонок,
  ChildItems пуст у 93%), multipleChoice, searchOnInput (Auto/Use/DontUse),
  markIncomplete (<AutoMarkIncomplete>, общий ключ с input).
- PictureField: hyperlink (<Hyperlink> — кликабельная картинка), shortcut (<Shortcut>).

Зеркало py. Кейсы table + picture-field расширены, сертифицированы загрузкой
в 1С. Регресс 39/39 в обоих рантаймах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:28:37 +03:00
Nick Shirokov 8bff8d7e05 fix(form-compile,form-decompile): Table <Height> vs <HeightInTableRows> — развод свойств
DSL зря сводил оба тега в height, а компилятор для таблицы всегда эмитил
<HeightInTableRows> → таблица с <Height>5</Height> регенерилась как
<HeightInTableRows>5</HeightInTableRows> (форма БизнесСеть/ВыборОрганизации).
Это РАЗНЫЕ свойства (высота элемента vs высота в строках), сосуществуют в 237
таблицах корпуса (Height-only 1242, HeightInTableRows-only 755).

- Развёл: height → <Height> (generic Emit-Layout, как у всех элементов),
  новый heightInTableRows → <HeightInTableRows>.
- Декомпилятор: Add-Layout ловит <Height> → height, Table-блок
  <HeightInTableRows> → heightInTableRows.

Зеркало py. Кейс table расширен (оба тега), сертифицирован в 1С (платформа
принимает оба). Регресс 39/39 в обоих рантаймах. Раундтрип TOTAL 68→61.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:12:56 +03:00
Nick Shirokov 4916f5bf7c fix(form-compile,form-decompile): UseAlways маркер "~" (query-поля дин-списка) — двойной префикс
Компилятор-баг: поле "~Список.Остановлен" (декомпилятор хранил verbatim) не
матчило проверку префикса ^Список\. → добавлялся ещё префикс →
"Список.~Список.Остановлен" (8+ форм выборки: ЗаявкаСотрудника*, Банки,
ОбеспечениеПроизводственныхПроцессов…). "~" — легитимный маркер query-полей
динамического списка (2234/17266 = 13% корпуса).

- Компилятор: префикс ИмяРеквизита. ставится ПОСЛЕ "~" (~Остановлен →
  ~Список.Остановлен); полная форма ~Список.X — verbatim (forgiving ввод).
- Декомпилятор: компактит ~Список.X → ~X (единообразно с короткими именами;
  компилятор разворачивает обратно).

Зеркало py. Кейс dynamic-list-form расширен (~Артикул + Список.Code +
Description), сертифицирован загрузкой в 1С. Регресс 39/39 в обоих рантаймах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:44:36 +03:00
Nick Shirokov 7882c1cc2b feat(form-decompile,form-compile): InputField choice-скаляры verbatim + Button Check + FixingInTable + слой рус.синонимов ключей
Раундтрип TOTAL 121→86 (−35), match 114→124.

- InputField choice-кнопки: захват/эмит «как есть» (ChoiceButton/ClearButton/
  DropListButton/SpinButton эмитились только при true, теряя явный false;
  ChoiceButton=true сидел под лишним StartChoice-гейтом). Убран дефолт
  choiceButton=true у ref-полей в from-object — вывод не изменился.
- Новые InputField-скаляры: choiceListButton/quickChoice/autoChoiceIncomplete
  (bool), choiceForm/choiceHistoryOnInput/choiceFoldersAndItems/footerDataPath (value).
- Button checked → <Check>true</Check> (пометка toggle-кнопки). Ключ checked,
  не check (check — тип-ключ CheckBoxField, был бы конфликт диспетчера типов).
- fixingInTable — в generic-скаляры (input/labelField/колонки).
- Общий слой русских синонимов ключей-свойств ($propSynonyms/PROP_SYNONYMS):
  Пометка/Заголовок/Ширина/КнопкаВыбора/ФормаВыбора/… → канон. Нормализация
  в Emit-Element рядом с тип-синонимами; case/space-insensitive; англ. побеждает.

Зеркало py (контент байт-в-байт). Кейсы input-fields/table/synonyms расширены
и сертифицированы загрузкой в 1С. Регресс 39/39 зелёный в обоих рантаймах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:34:15 +03:00
Nick Shirokov 4a68a977dc feat(form-decompile,form-compile): collapsedTitle — заголовок свёрнутой группы
Ключ collapsedTitle у группы → <CollapsedRepresentationTitle> (мультиязычный
текст, как title/inputHint). Заголовок свёрнутого представления у collapsible/
popup групп. 172 файла в корпусе, не обрабатывался.

Зеркало py (байт-в-байт). Кейс groups сертифицирован в 1С. Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:51:03 +03:00
Nick Shirokov ab9b4c6197 fix(form-decompile): ExtendedTooltip own-content — formatted мультиязычный Title
Регресс из ExtendedTooltip own-content коммита: при own-layout (Width/Height/…) +
форматированный мультиязычный Title объектная форма теряла текст и formatted.

Причины: (1) merge брал $textVal['text'] на плоском мультиязычном {ru,en} (без
ключа text) → null-текст → пустой <Title>; (2) formatted не захватывался явно, а
компилятор не re-детектит markup на мультиязычном dict → formatted="false".

Фикс: разводим обёртку {text,formatted} от мультиязычного {ru,en} по наличию
ключа 'text'; formatted берём ЯВНО из атрибута <Title formatted>.

Форма erp/МобильноеПриложениеЗаказыКлиентов/ФормаГлавногоУзла → round-trip match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:36:41 +03:00
Nick Shirokov f7d5e2fd00 fix(form-decompile,form-compile): PictureDecoration имя-как-Ref + <xr:Abs> + порядок Title декораций
Баг: Emit-PictureDecoration брал $el.picture (тип-ключ = имя элемента) фолбэком
источника картинки → при отсутствии src писал <xr:Ref>ИмяДекорации>. Фикс:
источник картинки — ТОЛЬКО src.

<xr:Abs> (встроенная картинка, 131 в корпусе): декомпилятор ловил лишь xr:Ref →
теперь src:"abs:Имя" → <xr:Abs>Имя</xr:Abs> (префикс abs:, иначе <xr:Ref>).

Порядок: LabelDecoration эмитил Title перед own-content, а платформа — layout-first
(корпус 16970 vs 44). Переставил флаги/hyperlink/layout/оформление ПЕРЕД Title (как
ExtendedTooltip) — заодно убирает шум атрибуции харнесса на многострочном Title
(Height «уезжал» на родительскую группу; контент был корректен, ломалась line-
атрибуция). Форма МобильноеПриложениеПредприниматель → round-trip match.

Зеркало py (байт-в-байт). Снэпшоты events/element-appearance/additional-columns
обновлены (только порядок) и пере-сертифицированы в 1С. Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:27:37 +03:00
Nick Shirokov 8915e99ac8 feat(form-decompile,form-compile): MobileDeviceCommandBarContent (состав моб-панели)
Форменное свойство <MobileDeviceCommandBarContent> = список имён командных
панелей/кнопок для командной панели мобильного устройства. DSL:
mobileCommandBarContent: ["Имя1", "Имя2", …] (массив строк).

Константы подтверждены по корпусу (161 файл): Presentation пустой,
CheckState=0, Value xsi:type=xs:string всегда — варьируется только имя-Value.
Компилятор ставит константы, эмитит перед AutoCommandBar; декомпилятор
собирает список имён.

Зеркало py (байт-в-байт). Форма BusinessProcesses/Задание/ФормаСписка →
round-trip match. Кейс commands сертифицирован в 1С. Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:11:18 +03:00
Nick Shirokov ea43522b5a feat(form-decompile,form-compile): ExtendedTooltip own-content (объектная форма)
ExtendedTooltip — это LabelDecoration: может нести own-content (layout/оформление/
флаги/hyperlink) вместо/вместе с текстом. Объектная форма extendedTooltip:
{ text?, formatted?, width?, autoMaxWidth?, maxWidth?, height?, horizontalStretch?,
verticalAlign?, titleHeight?, hyperlink?, visible?, enabled?, textColor?, font?, … }.
Дизамбигуация от текст-формы (строка/ML/{text,formatted}) — по наличию структурного
ключа. Переиспользует Emit-Layout/Emit-Appearance/Emit-CommonFlags + Emit-GenericScalars.

Порядок: own-content ПЕРЕД Title (в корпусе layout-first 582 vs 10) — заодно убирает
шум атрибуции харнесса на многострочном контенте. Декомпилятор собирает объект
(Add-Layout/Add-GenericScalars/Add-Appearance/флаги/hyperlink), текст → .text;
текст-only остаётся строкой (обратная совместимость).

Форма WildberriesПереходРВБ: TOTAL 35→2 (остаток — отдельный rowsPicture
LoadTransparent). Зеркало py (байт-в-байт), кейс input-fields (own-content+текст и
width-only) сертифицирован в 1С. Регресс 39/39 ps1+py. Хвост: События компаньона
(нужно имя-обработчик) — отложено.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:55:17 +03:00
Nick Shirokov fb3bce5811 feat(form-decompile,form-compile): пачка простых скаляров (generic pass-through + точечные)
Generic pass-through таблица простых скаляров элемента (captured/emitted «как
есть»): verticalAlign, throughAlign, enableContentChange, pictureSize, titleHeight,
childItemsWidth, showLeftMargin, cellHyperlink, viewMode, verticalScrollBar,
rowInputMode, mask, createButton. Один Emit-GenericScalars в Emit-Layout (покрывает
все элементы) + Add-GenericScalars в пост-обработке декомпилятора (skip-if-present —
специфичная обработка побеждает).

Точечно: Command>modifiesSavedData (bool), Page>showTitle:false, фикс InputField>
textEdit (компилятор эмитил, декомпилятор не ловил).

Баг-фикс: <FillChecking> в схеме НЕТ (0 в корпусе) — реальный тег <FillCheck>
(значение ShowError). Ключ fillCheck (синоним fillChecking, forgiving bool→ShowError).

TOTAL 408→246 (−162), match 81→107. Зеркало py (байт-в-байт). Кейсы groups/commands/
attributes-types/pages сертифицированы в 1С. Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:50:42 +03:00
Nick Shirokov 8ac0dfefd0 feat(form-decompile,form-compile): valueType — пустой Settings (список без ограничения типа)
Пустой <Settings xsi:type="v8:TypeDescription"/> у ValueList (список без
ограничения типа) — частый случай (в корпусе пустых 1893 vs непустых 864).
Три состояния valueType: нет ключа → нет Settings; "" → пустой <Settings…/>;
тип → с типом. Компилятор эмитит по присутствию ключа (включая ""); декомпилятор
для пустого Settings пишет маркер "". (ValueList без Settings вовсе — 58%, без
ключа valueType.)

Зеркало py. Кейс attributes-types: + СписокЛюбой с valueType:"" (пустой Settings),
сертифицирован в 1С. Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:52:46 +03:00
Nick Shirokov 9f672044ff feat(form-decompile,form-compile): valueType — тип значений ValueList (Settings TypeDescription)
Ключ valueType у реквизита формы: <Settings xsi:type="v8:TypeDescription"> —
уточнение типа значений у реквизита типа ValueList (СписокЗначений). В корпусе
341/341 именно на ValueList. Грамматика значения = как у type (включая составной
"A | B" и квалификаторы string/decimal/date).

Forgiving-синонимы: typeDescription (1С «ОписаниеТипов» / зеркало XML xsi:type),
описаниеТипов, типЗначений — канон valueType (декомпилятор пишет его). ≠ дин-список
Settings (xsi:type="DynamicList", отдельный ключ settings).

Emit-Type параметризован тегом-обёрткой (Type / Settings), Decompile-Type работает
на любом type-узле. Зеркало py (байт-в-байт). Кейс attributes-types (составной
string(50)|decimal(10,2)) сертифицирован в 1С. Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:23:16 +03:00
Nick Shirokov 7a2c257721 feat(form-compile): forgiving платформенные v8-типы (StandardPeriod/StandardBeginningDate/UUID)
Помогаем модели правильно вывести тип: принимаем англ. без префикса и рус.имя,
приводим к каноничному v8:X (эмитится verbatim):
- StandardPeriod / СтандартныйПериод → v8:StandardPeriod
- StandardBeginningDate / СтандартнаяДатаНачала → v8:StandardBeginningDate
- UUID / УникальныйИдентификатор → v8:UUID
- СписокЗначений → ValueList (v8:ValueListType)

Раньше bare StandardPeriod падал в "Unrecognized bare type" и эмитился без
префикса (невалидно). Input-сахар (декомпилятор пишет каноничный v8:). Зеркало
py. Кейс attributes-types использует рус. СтандартныйПериод (вывод тот же,
сертифицирован). Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:07:14 +03:00
Nick Shirokov 5a67c56e92 feat(form-decompile,form-compile): Save — сохранение значения реквизита в польз. настройках
Ключ save на реквизите формы (<Save><Field>…):
- true → <Field>имя</Field> (голое имя, 93% случаев)
- строка/массив строк → под-поля с авто-префиксом "имя." (путь с точкой,
  UUID 1/0:guid, или совпадающее с именем — берутся как есть)
- нет ключа или false → не эмитим

Гипотеза подтверждена на корпусе: Field=имя в 383/410 (93%); gating формовыми
SaveDataInSettings/AutoSaveDataInSettings НЕ требуется (51/160 форм с Save без них).
Период-кейс (голое имя Период + Период.EndDate/StartDate/Variant) round-trip
бит-в-бит. Переиспользует механику нормализации useAlways. ≠ savedData (отдельное,
уже было). Декомпилятор: один Field=имя → save:true, иначе массив со снятым
префиксом. Зеркало py (байт-в-байт), кейс attributes-types сертифицирован в 1С.
Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:59:55 +03:00
Nick Shirokov 09c3dcd988 feat(form-decompile,form-compile): HorizontalLocation у CommandBar (хвост дополнений)
Свойство horizontalLocation у элемента cmdBar (<CommandBar>): auto (дефолт,
не эмитим) / left / right / center, forgiving + рус.синонимы. Переиспользует
Get-HLocation от дополнений (+ добавлен center). Только у <CommandBar> в корпусе
(104 шт, в осн. Right), не у ButtonGroup/Popup/AutoCommandBar.

Компилятор (ps1+py байт-в-байт) + декомпилятор (захват <HorizontalLocation>).
Форма с CommandBar HorizontalLocation → round-trip match. Кейс commands расширен,
сертифицирован в 1С. Регресс 39/39 ps1+py. Закрывает остаток CommandBar>HorizontalLocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:22:39 +03:00
Nick Shirokov d06c2c49bb feat(form-decompile,form-compile): дополнения командной панели таблицы (Search/ViewStatus/SearchControl)
Дополнения встроенного поиска таблицы как тип-элементы — обе позиции:

(1) Кастомные (в AutoCommandBar/ChildItems) → элементы в commandBar:
    { "searchString": "Имя", "source": "Список", "width": 15, ... }
    Полный набор свойств поля (Emit-Layout/Appearance/CommonFlags/tooltip);
    source дефолт = родительская таблица; horizontalLocation auto/left/right.

(2) Стандартные (авто-генерация на уровне таблицы) → per-table карта
    отклонений additions: { viewStatus: { horizontalLocation: "left" } }.

Тип-как-ключ searchString/viewStatus/searchControl, forgiving-синонимы
(XML-тег, <Type>, рус.имя, имя «Вид» из конфигуратора). Декомпилятор разводит
по позиции (ChildItems → commandBar.children; прямые дети <Table> → карта
additions, только deviations); убран из COMPANION_TAGS, +ELEMENT_KEY.

Хвост: CommandBarLocation авто-вывод для дин-список-таблицы — суппресс-маркер
"" (компилятор инжектит None, верно по корпусу 203≈213; декомпилятор инвертирует:
нет тега → "", None → опускает, иначе → захват).

Зеркало py (байт-в-байт). Синтет-фикстура (upload/epf/ДополненияКП) — perfect
round-trip LOST 0/ADDED 0. Кейс dynamic-list-form расширен (кастомное+override),
сертифицирован в 1С. Регресс 39/39 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:04:52 +03:00
Nick Shirokov 01b598ce8f test(form): регресс-кейсы на уникальность имён + позитивный гард неймспейсов
Негативные кейсы (ожидают [ERROR] + exit 1):
- form-compile: дубль имени элемента / дубль имени команды;
- form-edit: добавление элемента с уже существующим в форме именем;
- form-validate: форма с дублирующимся именем элемента.

Позитивный гард (компилируется без ошибок):
- form-compile: имя реквизита == имя элемента — легально, раздельные неймспейсы;
  защищает emit_element от случайного слияния пулов имён.

Дополнительно прогнано на 38 781 реальной форме выгрузок ERP/ACC/УНФ —
ноль ложных срабатываний новой проверки.

Co-authored-by: brake71 <8448482+brake71@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:32:09 +03:00
Nick Shirokov 41e4714773 fix(form): уникальность имён во всех коллекциях форм + префикс колонок субконто ЧПС
Проверка уникальности имён элементов форм (основа — PR #21 от brake71),
портированная на актуальную ветку и расширенная на все именованные коллекции.

Корень проблемы: генератор формы счёта ПланаСчетов строил колонки таблицы
субконто с «голыми» именами (Валютный, ТолькоОбороты, ВидСубконто), из-за чего
флаг субконто сталкивался с одноимённым признаком учёта счёта → невалидный для
1С XML (форма не открывалась). Теперь имена колонок префиксуются именем таблицы
(ВидыСубконтоВалютный) — как делает generic-путь табчастей и типовая 1С.

- form-compile: fail-fast проверка уникальности в едином emit_element + по
  реквизитам, колонкам (в пределах реквизита), параметрам и командам. Хелпер
  вместо копипаста; проверка после нормализации синонимов.
- form-validate: проверка имён симметрично существующим id-пулам (элементы,
  реквизиты, колонки, команды) + новый блок параметров.
- form-edit: дедуп внутри JSON-определения и против существующих в форме —
  для элементов (рекурсивно), реквизитов (+колонки) и команд; WARN→ERROR.

Каждая коллекция — свой неймспейс (имя реквизита и имя элемента могут совпадать
легально). PS1 и PY — зеркальны. Версии: form-compile 1.74, form-validate 1.7,
form-edit 1.1. Все тест-сеты зелёные на обоих рантаймах.

Co-authored-by: brake71 <8448482+brake71@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:16:12 +03:00
Nick Shirokov ad89929efd fix(form-compile): актуализация allowlist knownKeys (оформление + autoCmdBar)
Allowlist дрейфовал — element-level appearance-ключи (11 канонических +
12 рус.синонимов: textColor/цветтекста/font/шрифт/border/рамка/title*/footer*)
читаются через Get-AppearanceValue и НЕ переименовываются, поэтому сыпали
ложный warning «unknown key» для валидных свойств закрытого кластера Appearance.

Решение самоподдерживающееся: union allowlist с самими структурами
appearance — ps1 foreach по appearanceSpec/appearanceSynonyms.Keys после
литерала; py доп. проверка `not in APPEARANCE_SPEC/APPEARANCE_SYNONYMS`.
Не дрейфует при добавлении новых ключей/синонимов. + статический autoCmdBar.

Проверено: appearance-ключи (canonical + рус.синонимы) не предупреждают,
реальные опечатки (textColorr/bogusKey) по-прежнему ловятся — оба рантайма.
Регресс 36/36 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:10:32 +03:00
Nick Shirokov 929d1676bb feat(form-decompile,form-compile): формат и формат редактирования (Format/EditFormat)
Ключи format/editFormat на InputField/LabelField/CheckBoxField (1408/958
файлов в корпусе). LocalStringType как inputHint/title: строка или {ru,en} —
переиспользование Emit-MLText (компилятор) и Get-LangText (декомпилятор).
Декомпилятор: хелпер Add-FormatProps в 3 ветках. Зеркало py.

Round-trip чистый (0 остатка на 30 формах с Format/EditFormat). Кейс
input-fields: format/editFormat строкой на input/check + мультиязык-объект
на labelField; снэпшот сертифицирован загрузкой в 1С.

Заодно добавлены забытые ключи (format/editFormat/choiceParameters/
choiceParameterLinks/typeLink) в allowlist knownKeys (ps1+py), чтобы не
сыпать ложный warning «unknown key».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:53:29 +03:00
Nick Shirokov d0bb26b068 feat(form-decompile): пустой <Presentation/> в choiceList → presentation:"" (суппресс-маркер)
Декомпилятор фиксирует пустой <Presentation/> элемента choiceList как
presentation:"" вместо опускания ключа. Раньше пустота терялась →
компилятор додумывал presentation из значения (Title-FromName для
DesignTimeRef) → LOST <Presentation/> + ADDED непустой <Presentation>.

Паттерн B (суппресс-маркер ""): компилятор УЖЕ обрабатывал presentation:""
корректно (hasPres=true → авто-вывод пропускается; Emit-ChoicePresentation
на "" → пустой <Presentation/>). Правка только в декомпиляторе.

Presentation ушёл из diff на Билеты/КлассификаторПАТСАТУРН/ОстаткиПартийСАТУРН
(подтверждено round-trip). Decompiler-only → регресс через harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:32:25 +03:00
Nick Shirokov f9ae24a678 fix(form-compile): пустая строка-значение self-closing + Enum.X.EmptyRef без EnumValue
Два общих бага value (всплыли на полном прогоне 2.17, чинят и choiceList,
и параметры выбора):

- Пустое строковое значение эмитилось <Value xsi:type="xs:string"></Value>
  вместо самозакрывающегося <Value xsi:type="xs:string"/>. Введён хелпер
  Get-ChoiceValueTag (3 места: choiceList scalar + choiceParam scalar +
  FixedArray inner). Форма АктивныеПользователи теперь round-trip match.
- Enum.X.EmptyRef нормализатор ломал вставкой .EnumValue. → EnumValue.EmptyRef
  (EmptyRef — пустая ссылка перечисления, не значение). Фикс в Normalize-
  ChoiceValue (Enum-ветка): EmptyRef сохраняется как есть.

Зеркало py. Кейсы: input-fields (пустая строка в choiceList), radio-auto-enum
(Enum.X.EmptyRef) — оба сертифицированы загрузкой в 1С. Регресс 36/36 ps1+py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:27:04 +03:00
Nick Shirokov 339c70b457 feat(form-compile): xs:dateTime в значениях параметров выбора / choiceList
Авто-детект ISO-datetime (^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$) в
Normalize-ChoiceValue → xs:dateTime вместо xs:string. Без изменения DSL:
декомпилятор уже отдаёт строку, компилятор распознаёт формат. Заодно
исправляет choiceList со значениями-датами (та же FormChoiceListDesTimeValue).

Round-trip бит-в-бит на 3 формах с датами в параметрах выбора. Кейс
input-fields: Отбор.Дата в объектной и короткой формах; снэпшот
сертифицирован загрузкой в 1С. Закрывает микро-хвост кластера ChoiceParameters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:11:34 +03:00
Nick Shirokov 65f63fbc48 feat(form-compile): короткая форма для параметров выбора / связей / связи по типу
Входной сахар (декомпилятор по-прежнему пишет объектную модель):
- choiceParameters: ["Отбор.Х=true", "Отбор.Вид=Enum.A, Enum.B"] — name=value,
  запятые → массив, литералы коэрсятся (true/false → bool, число → number,
  остальное → строка/ref через Normalize-ChoiceValue).
- choiceParameterLinks: ["Отбор.Орг=ОбычноеПоле", "Отбор.Тип=Поле:DontChange"] —
  name=dataPath, опц. хвост :Clear/:DontChange (дефолт Clear).
- typeLink: "ПолеПодсказка" или "ПолеПодсказка#0".

Парсеры на входе каждого эмиттера (строка → объект), далее общий путь.
Байт-в-байт идентично объектной форме (проверено ps1+py). Кейс input-fields:
поле ПолеСвязиКратко на короткой форме рядом с объектным; снэпшот
сертифицирован загрузкой в 1С. Версия компилятора синхронизирована (py 1.67→1.69).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:05:30 +03:00
Nick Shirokov 0397d8f37e feat(form-decompile,form-compile): параметры выбора / связи параметров выбора / связь по типу (кластер ChoiceParameters)
Покрытие трёх конструкций поля ввода (InputField), массовых в корпусе
(ChoiceParameters 844 / ChoiceParameterLinks 685 / TypeLink 84 файлов):

- choiceParameters: [{name, value}] — параметры выбора. value через общий
  Normalize-ChoiceValue (bool/число/строка/ref-путь + синонимы Перечисление./
  Справочник.); массив значений → v8:FixedArray. Presentation всегда пустой.
- choiceParameterLinks: [{name, dataPath, valueChange?}] — связи параметров
  выбора. valueChange дефолт Clear (опускается декомпилятором), forgiving
  Clear/DontChange + рус.синонимы. DataPath xsi:type=xs:string.
- typeLink: {dataPath, linkItem} — связь по типу. linkItem дефолт 0.

Декомпилятор: регистрация app namespace; инверсные хелперы (FixedArray →
массив, дефолт Clear опускается). Компилятор ps1 + зеркало py (байт-в-байт).
Spec: секция в #### input. Тест-кейс input-fields расширен полем со всеми
тремя конструкциями; round-trip бит-в-бит на 3 реальных формах; снэпшот
сертифицирован загрузкой в 1С 8.3.24.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:59:26 +03:00
Nick Shirokov d88859580f feat(form-compile): оформление PictureDecoration — un-defer (хвост Appearance закрыт)
Ранее отложил PictureDecoration, ошибочно решив, что падение загрузки = порядок тегов (XSD
расщепляет appearance вокруг <Title>). Разбор остатка показал: падало из-за НЕВАЛИДНОЙ ссылки
на картинку в тесте (picture:"Картинка" → <xr:Ref>Картинка</xr:Ref>, такой CommonPicture нет),
а не из-за оформления. Проверка с валидной StdPicture.Print + appearance → грузится чисто.

Ключевой факт: 1С ТОЛЕРАНТНА к порядку оформления внутри элемента. LabelDecoration в корпусе
тоже расщепляет appearance вокруг Title (TextColor/Font до Title — тысячи раз; BackColor/Border
после), но компилятор эмитит contiguous-после-Title — и LabelDecoration сертифицировался. Значит
профиль decoration валиден и для PictureDecoration.

Разведён 12-й эмиттер (pictureDecoration, профиль decoration), PS+Python. Кейс element-appearance
расширен PictureDecoration (StdPicture.Print, чистое имя через src). Сертификация загрузкой —
чисто. Регресс 36/36 ps+py. Harness: остаток appearance LOST = 0 (был PictureDecoration 2),
TOTAL 1146→1144; весь кластер Appearance 1326→1144 (−182). Версия form-compile v1.67.
2026-06-07 21:20:23 +03:00
Nick Shirokov d5d525aa27 feat(form-compile): оформление групп/таблиц/picField/calendar + декод \u-комментариев (хвост Appearance)
Развёл Emit-Appearance ещё в 5 эмиттеров: UsualGroup/ColumnGroup/Table/PictureField/CalendarField
(профиль field; декомпилятор их уже захватывал в Add-CommonProps — теперь компилятор эмитит).
Порядок собственного оформления по корпусу: группа TitleTextColor/TitleFont/BackColor; таблица
BackColor/BorderColor — укладываются в field-профиль. Зеркало PS+Python.

PictureDecoration НЕ разведён намеренно: его XSD расщепляет оформление вокруг <Title>
(TextColor/Font до Title, Border после) + позиция <Picture> — отдельный мелкий кластер (2 строки).

Сертификация загрузкой в 1С 8.3.24: element-appearance (+ группа с BackColor),
dynamic-list-parameters (+ Table backColor/borderColor, колонка titleTextColor/border) — чисто.
Регресс 36/36 ps+py. Harness 1177→1146 (−31; весь кластер Appearance 1326→1146 = −180),
остаток appearance LOST = PictureDecoration 2, ADDED-регрессий 0.

Попутно: декодированы garbled \u-escape в КОММЕНТАРИЯХ form-compile.py (артефакт Edit-инструмента,
переэкранирующего кириллицу под ASCII-конвенцию файла; в комментариях \u не интерпретируется).
Строковые литералы (имена компаньонов) остаются \u-escaped — там escape функционален.
Версия form-compile v1.66.
2026-06-07 21:03:18 +03:00
Nick Shirokov 1888952a41 feat(form-decompile,form-compile): оформление элементов — цвета/шрифты/граница (кластер Appearance)
Прямые свойства оформления элемента: <TextColor>/<BackColor>/<BorderColor> + header/footer
(<TitleTextColor>/<FooterBackColor>/…), <Font>, <Border>. Раньше терялись при декомпиляции и
не эмитились. Выборка 2.17: LabelDecoration>TextColor 49, InputField>Border/TextColor, Button*3,
LabelField header/footer (колонки).

DSL: ключи англ. camelCase 1:1 с тегами (textColor/backColor/borderColor/titleTextColor/…/
font/border) + приём рус. синонимов (ЦветТекста/ЦветФона/ЦветРамки/Шрифт/Рамка/…Заголовка/…Подвала).
- Цвет — verbatim-строка: style:/web:/win:/#RRGGBB (компилятор не валидирует; win: валиден —
  win:MenuBar/ButtonText/…; sys: в выгрузках не встречается; несуществующее имя → ошибка загрузки).
- Шрифт — строка "style:X" → <Font ref kind=StyleItem/> (минимальная форма); объект → только
  заданные атрибуты (ref/faceName/height/bold/italic/underline/strikeout/kind/scale), дефолты не
  досочиняются. Декомпилятор: чистый style-ref → строка, иначе объект (точный набор атрибутов).
- Граница — строка/{ref} → <Border ref="style:X"/>; {width,style} → явная (ControlBorderType:
  Single/Double/Underline/DoubleUnderline/Overline/Embossed/Indented/WithoutBorder).

Порядок тегов в XML — XSD-профиль по базовому типу (field/decoration/button), компилятор
расставляет сам; вставка перед компаньонами. Декомпилятор — захват в Add-CommonProps (все типы).
Компилятор разведён в 6 эмиттеров (input/check/radio/label/labelField/button) + зеркало Python.

Валидация: round-trip фикстура ПроверкаПользовательскихНастроек — 10/10 LabelField (стили рамки,
header/footer, Absolute/style шрифты) бит-в-бит. Сертификация загрузкой в 1С 8.3.24 (кейс
element-appearance: decoration/field/button, цвета hex/web/style, шрифты, границы) — чисто.
Регресс 36/36 ps+py. Harness sample-2.17 TOTAL 1326→1177 (−149), 0 ADDED-регрессий.
Версии: form-compile v1.65, form-decompile v0.47. Попутно: декод garbled \u-комментариев в .py.

Остаток (декомпилятор захватывает, компилятор пока не эмитит — не регресс): UsualGroup 9, Table 9,
PictureDecoration 2 — отдельным шагом.
2026-06-07 20:42:53 +03:00
Nick Shirokov ff93d169a8 test(form-compile): сертификация снэпшота dynamic-list-parameters в 1С + фикс путей колонок
verify-snapshots: при ManualQuery поля дин-списка = псевдонимы запроса (Код/Наименование),
а не авто-пути mainTable (Code/Description). Колонки кейса ссылались на Список.Code/Description
→ "Неверный путь к данным" при загрузке. Поправил пути под псевдонимы запроса, перегенерил снэпшот.
Форма (ручной запрос + полная грамматика параметров: availableValues/use/denyIncompleteValues)
загружается в платформу 8.3.24 чисто. Сами schema-параметры платформа приняла без замечаний.
2026-06-07 19:32:26 +03:00
Nick Shirokov b692a81ea5 feat(form-decompile,form-compile): schema-параметры динамического списка (кластер DynamicList Settings>Parameters)
Захват/эмиссия <Parameter> (DataCompositionSchemaParameter) внутри <Settings xsi:type="DynamicList"> —
та же сущность, что параметры СКД, но обёртка <Parameter> + дети dcssch:. Ранее теряли при
декомпиляции и не умели эмитить (компилятор обрывал на Field*→MainTable). Корпус acc+erp 8.3.24:
123 формы, 406 параметров.

DSL: ключ settings.parameters переиспользует грамматику параметров СКД ОДИН-В-ОДИН (shorthand
"Имя [Заголовок]: Тип = Значение @valueList @hidden" + объектная форма; ключи value/type/
availableValues/inputParameters/use/denyIncompleteValues/expression/availableAsField). Новых
сущностей не вводим — модель переносит знание skd-параметров напрямую.

Контекстные дефолты дин-списка (паттерн «умный дефолт у всегда-эмитируемого тега», для модели
невидимы — просто опускает ключ):
- useRestriction: эмитим ВСЕГДА, дефолт true (в СКД дефолт false); false → объект useRestriction:false;
- title: авто из имени через Title-FromName (camelCase-split с сохранением аббревиатур); явный
  заголовок — только при отклонении от авто-вывода;
- value: пустое всегда xsi:nil, даже при известном типе (в отличие от типизированного пустого в СКД).

Канон. порядок детей по корпусу: name, title, valueType, value, useRestriction, expression,
availableValue*, valueListAllowed, availableAsField, inputParameters, denyIncompleteValues, use.

Декомпилятор PS-only (зеркало в py отложено, как и весь form-decompile); компилятор зеркалён в py.
Валидация: round-trip бит-в-бит на ФормаОстатков (субсет) + ПроверкаПользовательскихНастроек
(полная грамматика: availableValues/inputParameters/denyIncompleteValues/use). Регресс 35/35 ps+py.
Тест-кейс dynamic-list-parameters (+снэпшот). spec обновлён. Версии: form-compile v1.64, form-decompile v0.46.
2026-06-07 19:29:47 +03:00
Nick Shirokov bc53ee2a14 feat(form-decompile,form-compile): картинки заголовка/подвала колонок + объектная модель картинки-ссылки (кластер column-pictures)
Декомпилятор терял картинки колонок таблиц формы:
- HeaderPicture/FooterPicture (общие для любого поля-колонки: input/check/labelField/picField)
- ValuesPicture: захват флага LoadTransparent (раньше брался только Ref)
- EditMode у PictureField

Единый формат "картинка-ссылка" (headerPicture/footerPicture/valuesPicture):
скаляр (Ref, loadTransparent=false — частый случай по корпусу ~64%) ИЛИ
объект {src, loadTransparent:true} для отклонения. Платформа всегда эмитит
<xr:LoadTransparent>, дефолт DSL=false. Флаг на каждой картинке (на одном
поле их бывает несколько).

Компилятор: HeaderPicture эмитится сразу после <EditMode> (порядок XDTO
строгий — иначе LoadConfigFromFiles падает с XDTO-исключением).

Forgiving-объект для <Picture> кнопки/попапа/команды: общий хелпер
Emit-CommandPicture принимает скаляр ИЛИ {src, loadTransparent}, чтобы
модель могла описать картинку объектно по аналогии с headerPicture.
Полярность кнопки сохранена (дефолт true). Декомпилятор не трогали —
объект только на вход, раундтрип без изменений.

Раундтрип sample-2.17: TOTAL 1582→1457, dec-fail/compile-fail 0.
Снапшот picture-field пересертифицирован в 1С. Регресс 34/34 ps+python.
Декомпилятор v0.45, компилятор v1.63.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:16:38 +03:00
Nick Shirokov 0636ac6877 fix(form-decompile): направление группы — '' при отсутствии <Group> (кластер UsualGroup/ColumnGroup>Group)
Декомпилятор при отсутствии <Group> подставлял дефолт (vertical у UsualGroup,
horizontal у ColumnGroup), и компилятор его эмитил → <Group> додумывался там,
где в оригинале тега нет (ADDED: ColumnGroup>Group=103, UsualGroup>Group=65).

«Опустить дефолт» не подходит: на корпусе Vertical у UsualGroup в двух ролях —
явный <Group>Vertical</Group> (51205, 36%, хранить) и опускаемый дефолт (10%,
тега нет). 1C сериализует «Группировку», только если задана в конфигураторе,
даже Vertical. По значению неразличимы → нужен отдельный маркер «тега не было».

Ключ group/columnGroup — тип-дискриминатор, опустить нельзя. Поэтому нет <Group>
→ значение '' (тип сохраняется, направление не эмитим). Компилятор уже опускает
<Group> при пустом/нераспознанном значении — правка только в декомпиляторе.
spec для group/columnGroup обновлён.

TOTAL diff lines выборки 2.17: 1750 → 1582 (−168); match 23 → 31.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:46:52 +03:00
Nick Shirokov cfce486004 feat(form-decompile,form-compile): заголовок реквизита — суппресс-маркер "" + omit авто-вывода (кластер Attribute>Title)
Компилятор для не-main реквизита БЕЗ ключа title додумывал <Title> из имени,
хотя платформа реквизит без синонима хранит без <Title>. На корпусе (295609
реквизитов): 22% без <Title> — всем додумывался заголовок (ADDED Attribute>Title
= 170 в выборке).

Компилятор (ps1+py): эмиссия Title реквизита приведена к логике Emit-Title —
нет ключа → авто-вывод (кроме main); title "" → подавить (раньше "" был falsy
и уходил в авто-вывод — это и был баг); непустой → как есть.

Декомпилятор (ps1): нет <Title> → title:"" (суппресс-маркер); ru-only заголовок,
равный авто-выводу из имени → опускаем ключ (компилятор воспроизведёт, 35% =
103908 реквизитов корпуса); иначе → явный. Скопировано точное зеркало
Title-FromName для сверки.

Регресс: attributes-types.json — реквизит с title:"" (подавление) рядом с
авто-выводом + снэпшот. spec §реквизиты обновлён.

TOTAL diff lines выборки 2.17: 2255 → 1750 (−505); cascade ADDED 292 → 33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:33:02 +03:00
Nick Shirokov 2367eaa353 fix(form-compile): Page с направлением раскладки → не путать с UsualGroup
У Page/Pages ключ `group` означает направление раскладки детей
(<Group>Horizontal</Group>), а не тип UsualGroup. Диспетчер типа элемента
проверял `group` раньше `page`/`pages`, поэтому страница с горизонтальной
раскладкой компилировалась как <UsualGroup name="horizontal"> вместо <Page>.

Переставил `pages`/`page` перед `group` в TYPE_KEYS (ps1 + py зеркало) —
реальная UsualGroup ключа page/pages не несёт, конфликта нет. Emit-Page уже
корректно эмитит <Group> из ключа group.

Регресс: добавил group:horizontal на страницу в кейс pages.json + снэпшот.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:02:16 +03:00
Nick Shirokov 8448a28a29 feat(form-decompile,form-compile): loadTransparent картинки команд/кнопок/попапов (захват явного false)
Прозрачность картинки (<Picture><xr:LoadTransparent>) у Command/Button/Popup компилятор
хардкодил true, а в корпусе значение смешано (Command true 11410/false 8066;
Popup true 3142/false 2828). Явный false терялся.

Теперь компилятор эмитит loadTransparent факт. значение (дефолт true — платформа всегда
пишет тег внутри Picture; false при явном loadTransparent:false). Декомпилятор фиксирует
ТОЛЬКО отклонение false (true опускается — додумывается дефолтом, без шума в DSL).
Свойство плоское рядом с picture — консистентно с PictureDecoration(src)/PictureField(valuesPicture).

TOTAL diff lines выборки 2.17: 2489 → 2415 (-74). Command/Button/Popup LoadTransparent
residual → 0. Остаток (отдельный хвост): PictureField (HeaderPicture/valuesPicture),
CheckBoxField-cascade, Table rowsPicture — другие картиночные объекты. Снапшот button-group
(popup loadTransparent:false) сертифицирован в 1С (8.3.24). Регресс form-compile 34/34
зелёный на ps + python. decompile v0.42, compile v1.60.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:46:40 +03:00
Nick Shirokov 4430ebc42e fix(form-compile,form-decompile): Popup-с-картинкой → PictureDecoration (дизамбигуация типа) + Button DataPath
Корень: тип-детекция элемента видела ключ 'picture' РАНЬШЕ 'popup'/'button' в списке
типов. Но 'picture' это и тип (PictureDecoration), и свойство-иконка у popup/button/cmdBar
(оба строковые → не развязать по значению). Попап «{ popup, picture, representation }»
ошибочно компилировался как <PictureDecoration name="StdPicture.X"> → терялся весь попап
+ каскад. Решение: понизить приоритет picture/picField (в конец TYPE_KEYS) — тип-ключ
владельца выигрывает.

+ Button DataPath: кнопки общих команд несут <DataPath> (Объект.Ref, Items.X.CurrentData.Поле,
2706 в корпусе) — привязка команды к контексту. Не захватывался. Добавлен ключ path у button.

TOTAL diff lines выборки 2.17: 2727 → 2489 (-238). Снапшот button-group (+popup с картинкой)
сертифицирован в 1С (8.3.24). Регресс form-compile 34/34 зелёный на ps + python.
decompile v0.41, compile v1.59.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:28:21 +03:00
Nick Shirokov 5112dbec9e feat(form-decompile,form-compile): HorizontalStretch/VerticalStretch — захват явного false
Растягивание (<HorizontalStretch>/<VerticalStretch>) платформа эмитит явным значением
(false 38145 / true 25002 для HS; на Input/Label/Picture/Group/CommandBar). Декомпилятор/
компилятор работали только с true → явный false терялся.

Теперь захват и эмиссия фактического значения (true И false); отсутствие = дефолт
(не эмитим). Бэк-совместимо: true как раньше, +false. Раньше декомпилятор писал ключ
лишь при true — теперь и при false.

TOTAL diff lines выборки 2.17: 2912 → 2727 (-185), match 20 → 22. Stretch residual
92 → 1 (остаток — на companion ExtendedTooltip, отдельный кластер). Снапшот input-fields
(+stretch false) сертифицирован в 1С (8.3.24). Регресс form-compile 34/34 зелёный
на ps + python. decompile v0.40, compile v1.58.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:11:12 +03:00
Nick Shirokov 701d56b075 feat(form-decompile,form-compile): размазанный блок скаляров InputField + общие cell-свойства полей
Партия простых скаляров полей, которые не захватывались (платформа эмитит явное
не-дефолтное значение):
  - InputField-специфичные: Wrap, OpenButton, ListChoiceMode, ExtendedEditMultipleValues,
    ChooseType, ChoiceButtonRepresentation — в Emit-Input / InputField-кейс.
  - Общие cell-свойства поля-колонки (Input/Label/Picture/CheckBox/ColumnGroup):
    ShowInHeader, ShowInFooter, AutoCellHeight, FooterHorizontalAlign, HeaderHorizontalAlign —
    вынесены в общий Emit-CommonElementProps / Add-Layout (захват «как есть»).
    ColumnGroup: собственная эмиссия ShowInHeader убрана (общий путь покрывает) —
    устранён двойной эмит.

TOTAL diff lines выборки 2.17: 3179 → 2912 (-267), match 17 → 20 (+3 чистых формы).
Все обработанные скаляры residual → 0. Снапшот input-fields (+скаляры) сертифицирован
в 1С (8.3.24). Регресс form-compile 34/34 зелёный на ps + python.
decompile v0.39, compile v1.57.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:58:17 +03:00
Nick Shirokov 27f5da4829 feat(form-decompile,form-compile): поведение группы — ключ behavior (usual/collapsible/popup)
<Behavior> у UsualGroup: дефолт Авто (не эмитится), явные Обычное/Свертываемая/Всплывающая
эмитятся. Платформа пишет тег только при не-Авто (чистое правило эмиссии). Раньше
декомпилятор мапил только Collapsible (в group:'collapsible'), теряя явный Usual (28069)
и PopUp (141).

Развязаны group (направление) и behavior:
  - group: horizontal/vertical/alwaysHorizontal/alwaysVertical (направление);
  - behavior: usual/collapsible/popup → <Behavior>; отсутствие = Авто.
Legacy group:'collapsible' принимается (= vertical + behavior collapsible) — старые входы целы.

Collapsed обобщён: эмитится при collapsed:true независимо от behavior (в XML <Collapsed>
изредка есть и у PopUp/Usual — round-trip ловит фактическое наличие).

TOTAL diff lines выборки 2.17: 3347 → 3179 (-168). UsualGroup>Behavior residual → 0.
Снапшот groups (behavior usual + collapsible/collapsed) сертифицирован в 1С (8.3.24).
Регресс form-compile 34/34 зелёный на ps + python. decompile v0.38, compile v1.56.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:18:40 +03:00
Nick Shirokov 786bdf97d9 feat(form-decompile,form-compile): AdditionalColumns — доп. колонки табличных частей объекта
<Columns><AdditionalColumns table="Объект.ТабЧасть"><Column>…</AdditionalColumns></Columns>
у главного реквизита-объекта (3654 формы, 10187 блоков) — форма-определённые доп.
колонки табличных частей. Декомпилятор читал только прямые <Column> (SelectNodes lf:Column),
теряя AdditionalColumns целиком (часто весь <Columns> блок объекта).

Ключ реквизита additionalColumns: [{ table, columns: [<col>] }]; <col> — та же грамматика,
что у columns (name/type/title/functionalOptions). Общие хелперы Emit/Decompile-AttrColumn
(переиспользуются прямыми колонками и AdditionalColumns). Порядок схемы: прямые <Column>
сначала, затем AdditionalColumns-группы.

TOTAL diff lines выборки 2.17: 3695 → 3347 (-348). Attribute>Columns/AdditionalColumns
residual → 0. Новый кейс additional-columns (DataProcessor с табчастью + форма) сертифицирован
в 1С (8.3.24). Регресс form-compile 34/34 зелёный на ps + python.
decompile v0.37, compile v1.55.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:47:28 +03:00
Nick Shirokov 6056a4a5af feat(form-decompile,form-compile): UseAlways — поля реквизита, всегда читаемые (две формы DSL)
<UseAlways><Field>ИмяРеквизита.Поле</Field>…> у Attribute (5189: дин-список 3575 +
ValueTable ~788 + прочие) — не захватывался. Свойство «поля, всегда читаемые из БД».

DSL — две формы (сливаются компилятором):
  - на реквизите: useAlways: ["Поле1","Поле2"] (короткие имена; forgiving с/без префикса);
  - на колонке ValueTable: useAlways: true (columns[*]).
Компилятор собирает <Field>ИмяРеквизита.X</Field> из обоих источников (dedupe),
порядок схемы: после FillChecking, до FunctionalOptions/Columns/Settings.

Дин-список: колонки в XML не эмитятся, но если заданы в DSL с useAlways — формируют
UseAlways-массив (Columns подавляются при наличии settings).

Декомпилятор по контексту: ValueTable (есть columns) → useAlways:true на совпавшей
колонке; дин-список/прочие → массив useAlways на реквизите. Префикс «Имя.» снимается.

TOTAL diff lines выборки 2.17: 3869 → 3695 (-174), match 14 → 17 (+3 чистых формы).
Attribute>UseAlways residual → 0. Снапшот table (обе формы + merge) сертифицирован
в 1С (8.3.24). Регресс form-compile 33/33 зелёный на ps + python.
decompile v0.36, compile v1.54.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:29:39 +03:00
Nick Shirokov fdbfa3b643 feat(form-decompile,form-compile): функциональные опции + фиксы round-trip типов (ValueList/UUID/платформенные)
1) Функциональные опции (<FunctionalOptions><Item>FunctionalOption.X</Item>…>) у
   Attribute (4391) / Command (2385) / Column (1272) — не захватывались. Ключ
   functionalOptions (массив имён; forgiving "X"/"FunctionalOption.X"; GUID-опции
   расширений — как есть). Общий хелпер Emit/Decompile-FunctionalOptions (+py).
   Порядок: атрибут после FillChecking; команда после Action; колонка после Type.

2) ValueList round-trip баг: Decompile-Type switch без break → общий case
   ^(v8|v8ui|cfg): перетирал специфичный v8:ValueListType → выдавал «ValueListType»
   (голый), компилятор эмитил <v8:Type>ValueListType</v8:Type> без префикса.
   Добавлены break во все cases.

3) Платформенные типы без friendly-шортката (v8:UUID 3132, v8:StandardPeriod 233,
   v8:Null, v8:StandardBeginningDate, v8ui:VerticalAlign …) теряли префикс
   (декомпилятор снимал v8:, компилятор эмитил голый). Теперь декомпилятор оставляет
   префикс для не-friendly v8:/v8ui: типов (friendly — ValueTable/ValueTree/ValueList/
   TypeDescription/FormattedString/Picture/Color/Font — шорткат), компилятор эмитит
   токены с префиксом (v8:/v8ui:/xs:/dcs*:) verbatim. Покрыт весь хвост.

TOTAL diff lines выборки 2.17: 4068 → 3869 (-199). FunctionalOptions/ValueListType/UUID
residual → 0. Снапшот attributes-types (+ValueList, +v8:UUID) сертифицирован в 1С (8.3.24).
Регресс form-compile 33/33 зелёный на ps + python. decompile v0.35, compile v1.53.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:11:14 +03:00
Nick Shirokov 9b77f06aba feat(form-decompile,form-compile): наборы типов TypeSet (определяемый тип, характеристика, любая ссылка)
<v8:TypeSet> (набор типов) не поддерживался: Decompile-Type итерировал только v8:Type
→ тип колонки/реквизита/параметра с TypeSet терялся (компилятор эмитил пустой <Type/>).

Покрыто (9282 вхождения в корпусе):
  - DefinedType.X (6515) — определяемый тип (синоним ОпределяемыйТип.X)
  - Characteristic.X (216) — характеристика (синоним Характеристика.X)
  - AnyRef (268) / AnyIBRef (207) — любая ссылка / любая ссылка ИБ
  - голый ref-вид без .Имя: CatalogRef/DocumentRef/EnumRef/ExchangePlanRef/TaskRef/
    BusinessProcessRef/ChartOf*Ref — «любая ссылка вида»

Развязка с обычным типом — по наличию точки: CatalogRef.Валюты → <v8:Type>,
CatalogRef (голый) → <v8:TypeSet>. DefinedType/Characteristic/голый ref никогда не
бывают v8:Type (проверено: 0). Составной тип через " | " роутит каждую часть
независимо (в т.ч. смешанный Type+TypeSet).

Emit-SingleType (+py) детектит и эмитит <v8:TypeSet>; Decompile-Type снимает cfg:-префикс.
TOTAL diff lines выборки 2.17: 4443 → 4068 (-375), match 13 → 14. Снапшот table
(колонки AnyRef/CatalogRef) сертифицирован в 1С (8.3.24). Регресс form-compile 33/33
зелёный на ps + python. decompile v0.34, compile v1.52.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:59:40 +03:00
Nick Shirokov daf7f1526a feat(form-decompile,form-compile): choiceList у InputField (переиспользование радио)
ChoiceList (<ChoiceList>) встречается на RadioButtonField (уже было) и InputField
(2142 в корпусе) — не захватывался у InputField. Логику вынесли в общие хелперы
Emit-ChoiceList / Decompile-ChoiceList (PS1) и emit_choice_list (PY), подключили к
обоим полям. Грамматика та же: [ { value, presentation?/title? } ] (+ рус. синонимы),
авто-вывод presentation. Порядок в InputField: после input-свойств/InputHint, до companions.

TOTAL diff lines выборки 2.17: 5149 → 4443 (-706). InputField>ChoiceList закрыт
(остаток ~5 — другое: app:value в app-неймспейсе = списки выбора параметров/настроек;
ChoiceListButton = отдельное input-свойство). Снапшот input-fields сертифицирован в 1С
(8.3.24). Регресс form-compile 33/33 зелёный на ps + python. decompile v0.33, compile v1.51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:33:32 +03:00
Nick Shirokov 7eb825b3a7 feat(form-decompile,form-compile): commandSource у ButtonGroup/CommandBar
ButtonGroup/CommandBar несут <CommandSource> (источник команд группы): Form (2478),
FormCommandPanelGlobalCommands (1267), Item.<ИмяЭлемента> (команды конкретного
элемента-таблицы). Декомпилятор не захватывал → LOST в форменных/элементных панелях.

Добавлен ключ commandSource (эмитится «как есть», после Title до Representation/Autofill).
Декомпилятор захватывает у ButtonGroup и CommandBar.

TOTAL diff lines выборки 2.17: 5189 → 5149 (-40). ButtonGroup/CommandBar CommandSource
LOST → 0. Снапшот button-group (группа глобальных команд с commandSource) сертифицирован
в 1С (8.3.24). Регресс form-compile 33/33 зелёный на ps + python.
decompile v0.32, compile v1.50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:18:41 +03:00
Nick Shirokov c998139c89 feat(form-decompile): форменный AutoCommandBar — маркер autofill для голого корня
Корень формы (id=-1) при голом <AutoCommandBar/> (autofill=true по умолчанию) терялся:
эвристика B3 (Compute-MainAcbAutofill) при наличии cmdBar-элемента в форме додумывает
Autofill=false → ADDED. Тот же приём, что у таблицы: декомпилятор зеркалит условие
эвристики (Test-AnyCmdBar) и при голом корне + наличии cmdBar пишет отклонение
autoCmdBar:{ autofill: true }. Компилятор не менялся (Compute-MainAcbAutofill уже
возвращает mainAcbDef.autofill; при true без детей корень эмитится голым).

TOTAL diff lines выборки 2.17: 5249 → 5189 (-60). AutoCommandBar ADDED 18 → 0
(голый корень закрыт). Регресс form-compile 33/33 зелёный на ps + python.
decompile v0.31.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:08:01 +03:00
Nick Shirokov 7b945e786d feat(form-decompile,form-compile): дин-список AutoCommandBar — маркер отклонения вместо ADDED
Хвост кластера командных панелей: компилятор-эвристика додумывает Autofill=false
дин-список-таблицам (подавляет панель, чтобы не дублировать КП формы), но ~15%
таблиц имеют голый <AutoCommandBar/> (autofill=true по умолчанию — панель оставлена
при таблице). Раньше эвристика их перетирала → ADDED <Autofill>false>.

Решение (B): эвристика держит дефолт false (оптимальный — 85% дин-списков; контекст-
сигналы проверены по корпусу, ни один не даёт >50% «голых» → улучшать дефолт нечем),
а декомпилятор фиксирует ОТКЛОНЕНИЕ маркером commandBar:{ autofill: true }. commandBar
имеет приоритет над эвристикой.

Компилятор: Emit-CompanionPanel больше не пишет <Autofill>true</Autofill> (платформа
его не эмитит — true это дефолт); только <Autofill>false</Autofill>. autofill:true или
отсутствие → тег опускается, при пустой панели → self-closing <AutoCommandBar/>.

TOTAL diff lines выборки 2.17: 5293 → 5249 (-44). Table>AutoCommandBar ADDED 22 → 0
(полностью закрыт). Остаток AutoCommandBar 18 — форменная панель (heuristic B3, корень
формы) — отдельный пункт. Регресс form-compile 33/33 зелёный на ps + python.
decompile v0.30, compile v1.49.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:51:26 +03:00
Nick Shirokov a1a22d1ffe test(form-validate): обновить устаревшие снапшоты (table-form, compiled-form)
Снапшоты отстали от компилятора (table additions с AdditionSource +
ExtendedTooltip companion таблицы; CheckBoxType у CheckBoxField — из прошлых
правок form-compile) — перегенерированы под текущий вывод. Не связано с
логикой form-validate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:32:39 +03:00
Nick Shirokov 7c765137db feat(form-decompile,form-compile): контент командных панелей таблицы — commandBar/contextMenu (companion tier 2)
Крупнейший левередж-кластер. Companion-панели элемента (AutoCommandBar/ContextMenu)
теперь несут контент как СВОЙСТВА:
  - commandBar  → <AutoCommandBar> (командная панель)
  - contextMenu → <ContextMenu>   (контекстное меню)

Значение: массив = shorthand для { children }; объект { autofill?, horizontalAlign?,
children[] }. children — обычная грамматика button/buttonGroup/popup.

Forgiving-синонимы (commandBar ← autoCommandBar/AutoCommandBar/autoCmdBar/cmdBar/
КоманднаяПанель; contextMenu ← ContextMenu/КонтекстноеМеню). Разведение «тип-элемент
vs панель-свойство» — по ТИПУ значения: строка = элемент-тип в дереве (cmdBar:"Имя"),
объект/массив = companion-панель этого элемента. Тип-синонимы применяются только к
строковому значению. Механизм общий (любой элемент), декомпилятор захватывает в
Decompile-Element, компилятор — Emit-CompanionPanel.

Новый ключ кнопки commandName — глобальная команда «как есть» (CommonCommand.X,
Catalog.X.Command.Y) без обёртки Form. (раньше попадала в command и ошибочно
оборачивалась в Form.Command.). stdCommand/command без изменений.

Декомпилятор: для дин-список-таблицы пустой AutoCommandBar(autofill=false) не пишет
commandBar (восстановит heuristic) — без шума. tableAutofill остаётся shorthand,
commandBar имеет приоритет.

TOTAL diff lines выборки 2.17: 7560 → 5293 (-2267), match 11 → 13,
cascade LOST 3414 → 1805. Table>ContextMenu/Table>AutoCommandBar ушли из топа impact.
Снапшот table сертифицирован в 1С (8.3.24); регресс form-compile 33/33 зелёный
на ps + python. decompile v0.29, compile v1.48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:30:08 +03:00
Nick Shirokov d484a5b7ec feat(form-decompile,form-compile): доступ по ролям — userVisible/view/edit/use (единый xr-механизм)
Кластер «доступ по ролям»: единый role-adjustable boolean платформы
(xr:Common + 0..N xr:Value name="Role.X") для четырёх владельцев одним
грамматиком значения:
  - элемент   → userVisible  (<UserVisible>)
  - реквизит  → view, edit    (<View>/<Edit>)
  - команда   → use           (<Use>)

Значение DSL: скаляр false/true → голый <xr:Common>; объект
{ common, roles:{ Имя: bool } } → пер-ролевые исключения (три-state как в
конфигураторе: роль не указана → наследует common; указана → явный bool).
Имя роли forgiving: без префикса / Role. / Роль. → нормализуется в Role.
Отсутствие ключа = полный доступ (платформа тег не пишет) — дефолт не эмитим.

Декомпилятор инвертирует: голый Common → скаляр, есть Value → объект.
Компилятор: общий хелпер Emit-XrFlag / emit_xr_flag (ps1+py).
Порядок схемы: View → Edit после MainAttribute; Use после ToolTip до Action.

Раньше: userVisible умел только голый false (компилятор), декомпилятор не
захватывал ничего; view/edit/use не умел никто.

TOTAL diff lines выборки 2.17: 7911 → 7560 (-351), match 9 → 11.
Снапшоты attributes-types/commands сертифицированы в 1С (8.3.24);
регресс form-compile 33/33 зелёный на ps + python.
decompile v0.28, compile v1.47.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:41:04 +03:00
Nick Shirokov f34993b805 feat(form-compile): авто-генерация пустого ExtendedTooltip для Table/CommandBar/Popup (companion presence)
Платформа всегда ставит companion <ExtendedTooltip> на Table/CommandBar/Popup (подтверждено
корпусом: 13174/6086/15681 — 100%), но компилятор его не генерил (не было call-site в
Emit-Table/CommandBar/Popup) → пустой companion терялся при раундтрипе (вскрыто фиксом метрики).

Добавлена эмиссия ExtendedTooltip в Emit-Table (после AutoCommandBar), Emit-CommandBar и
Emit-Popup (перед ChildItems) — с контентом el.extendedTooltip (единый механизм). Зеркало
в form-compile.py идентично.

Валидация: ExtendedTooltip LOST 437→276 — Table(117→1)/CommandBar(46→0) закрыты, Popup
13→7; сертификация в 1С PASS; регресс 33/33 ps+py (снэпшоты перегенерированы — +ExtendedTooltip
и каскад перенумерации id); py==ps1; harness 8144→7911, match 8→9.

Остаток ExtendedTooltip LOST (Button 213 + ButtonGroup 34) — НЕ ExtendedTooltip: это cascade
от непокрытого контента AutoCommandBar (кастомные кнопки/ButtonGroup командной панели не
генерятся компилятором) — отдельный кластер. CheckBoxField 7 — ExtendedTooltip с own-layout
(<Width>). В BACKLOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:13:32 +03:00
Nick Shirokov b147e491ee feat(form-decompile,form-compile): единая ML-text форма для заголовков декораций (Label/Picture) — переиспользование Resolve-MLFormatted
Заголовок декорации — formatted-aware текст (как extendedTooltip). Раньше LabelDecoration
нёс formatted отдельным sibling-ключом, PictureDecoration терял атрибут formatted вовсе
(эмитил голый <Title> через generic Emit-Title). Теперь оба идут через общий
Emit-DecorationTitle → Resolve-MLFormatted (та же единая ML-text форма, что у extendedTooltip):
- title декорации: строка (formatted авто-детектится по разметке) / {ru,en} / {text, formatted}.
- атрибут <Title formatted="…"> эмитится ВСЕГДА (специфика декораций); для обычных элементов
  Emit-Title остаётся без formatted (formatted — только у декораций, подтверждено корпусом:
  LabelDecoration 6568 + PictureDecoration 2, прочие 0).
- back-compat: sibling-ключ formatted принимается как override авто-детекта; компилятор-вывод
  LabelDecoration не изменился.

Декомпилятор (v0.27): декорации захватывают title через Get-MLFormattedValue (гибрид);
sibling formatted больше не выводится (форматированные обычно становятся просто строкой
с markup внутри). Компилятор (ps1+py v1.45): Emit-DecorationTitle для Label+Picture.

Валидация: LabelDecoration formatted round-trip CLEAN; PictureDecoration Title-formatted
закрыт (29→0); регресс 33/33 ps+py; py==ps1; harness 8202→8144. Spec обновлён.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:43:08 +03:00
Nick Shirokov 684cd17d5f feat(form-decompile,form-compile): контент расширенной подсказки extendedTooltip + единая ML-text форма с formatted (кластер companion-content tier 1)
Companion <ExtendedTooltip> несёт <Title> (текст расширенной подсказки) — декомпилятор
пропускал companion целиком, текст терялся. Теперь companion = свойство родителя:
ключ extendedTooltip на элементе (синоним extTooltip).

Единая форма ML-текста (для title/tooltip/extendedTooltip):
- строка → ru; {ru,en} → многоязычно; {text, formatted: true} → форматированный.
- formatted: текст несётся RAW (1С inline-разметка <b>/<color>/<link>/</> — часть строки,
  round-trip через XML-экранирование, спаны не моделируем).
- Гибрид: флаг formatted авто-детектится по известной разметке/</> (детектор идентичен в
  ps1+py+декомпиляторе); явный {text,formatted} — только когда авто-детект неверен (~2%
  корпуса: formatted без разметки / литеральные <…>-плейсхолдеры). Авто-детект подтверждён
  данными (98% верно, мисматч 1003 из 53612).

Декомпилятор (v0.26): извлекает Title из companion <ExtendedTooltip> на родителя (гибрид).
Компилятор (ps1+py v1.44): Emit-Companion с опциональным контентом; 14 call-site'ов
ExtendedTooltip передают el.extendedTooltip; синоним extTooltip→extendedTooltip; whitelist.

Валидация: content-bearing round-trip CLEAN (Банки/ФормаЭлемента byte-identical, formatted=true
round-trip); регресс 33/33 ps+py; py==ps1 идентичны; harness 8368→8202.

Хвосты (в BACKLOG): пустое присутствие companion (Table/CommandBar/Popup не генерят
ExtendedTooltip — ~437, вскрыто фиксом метрики) и ExtendedTooltip с own-layout (<Width>).
Spec: extendedTooltip + раздел ML-text/formatted/markup-словарь.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:16:32 +03:00
Nick Shirokov e905d5f576 feat(form-decompile,form-compile): табличные скаляры ChoiceMode/SelectionMode/RowSelectionMode/Vertical-HorizontalLines + дегейт UseAlternationRowColor/InitialTreeView/RowPictureDataPath/RowsPicture (кластер Table scalars)
Свойства таблицы, терявшиеся на раундтрипе. Часть была захвачена в декомпиляторе под
gate динсписка (<UpdateOnDataChange>) → терялась на обычных ValueTable-таблицах.

Новые скаляры (захват + эмиссия, все типы таблиц):
- choiceMode (компилятор уже эмитил — добавлен захват), selectionMode (SingleRow/…),
  rowSelectionMode (Row/…), verticalLines/horizontalLines (явное false).
Дегейт (вынесены из блока динсписка в общую обработку Table — ловятся на ЛЮБОЙ таблице):
- useAlternationRowColor, initialTreeView, rowsPicture — захват/эмиссия без gate.
- rowPictureDataPath — инверсия умного дефолта DefaultPicture осталась дин-список-only;
  обычные таблицы захватывают/эмитят литерал.

Зеркало form-compile.py идентично (py==ps1 проверено).

Валидация: все 7 целевых — 0 LOST / 0 ADDED; round-trip на ValueTable-формах
(АдреснаяКнига и др.); регресс 33/33 ps+py; harness 8448→8368 (на честной метрике
после фикса атрибуции), 0 fail. Остаток по таблицам — companion-контент
(ExtendedTooltip/AutoCommandBar/ContextMenu) и цвета/шрифты — отдельные кластеры.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:14:32 +03:00
Nick Shirokov 1b56e7a735 feat(form-decompile,form-compile): честно-табличные свойства ChangeRowSet/Order, AutoInsertNewRow, EnableDrag, RowFilter (кластер Table group A)
Свойства редактируемых (ValueTable) таблиц формы, терявшиеся при раундтрипе:
- ChangeRowSet/ChangeRowOrder — теперь эмитятся явным значением, включая false
  (платформа пишет <ChangeRowSet>false</ChangeRowSet> на ValueTable; раньше компилятор
  эмитил только true → false терялся). Декомпилятор захватывает фактическое значение.
- AutoInsertNewRow — новый ключ (автодобавление строки), захват/эмиссия при true.
- EnableDrag — декомпилятор теперь захватывает (компилятор уже эмитил).
- RowFilter — nil-плейсхолдер <RowFilter xsi:nil="true"/> (в корпусе ВСЕГДА nil, 0 с
  контентом). DSL-ключ rowFilter: null; компилятор эмитит nil при наличии ключа.

Зеркало в form-compile.py идентично (py==ps1 проверено на ValueTable-формах).

Валидация: все четыре — 0 LOST / 0 ADDED (полностью закрыты); round-trip CLEAN на
ValueTable-формах (БанкиУниверсальногоОбмена, БанковскиеСчета); регресс 33/33 ps+py;
harness 7971→7774 (−197), 0 fail. Вывод байт-идентичен реальным формам платформы.

Spec: changeRowSet/changeRowOrder/autoInsertNewRow/enableDrag/rowFilter в table-секции.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:42:26 +03:00
Nick Shirokov a9e1ab64c8 feat(form-decompile,form-compile): общие свойства элемента DefaultItem/FileDragMode/EnableStartDrag/SkipOnInput (кластер generic element props)
Эти свойства — общие для любого типа элемента (таблица, поле, надпись, картинка,
кнопка), а не специфичны для таблицы. Раньше обрабатывались только в дин-список-блоке
Table → терялись на PictureDecoration/PictureField/LabelField/InputField/Button.

Перенесены в общий Emit-Layout/Add-Layout (универсальны — 17 вызовов компилятора,
один вызов декомпилятора на каждый элемент):
- DefaultItem (элемент по умолчанию), EnableStartDrag, FileDragMode — захват при наличии.
- SkipOnInput — теперь эмитится явное значение, включая false (раньше только true);
  декомпилятор захватывает фактическое значение.
- Вынесены в helper Emit-CommonElementProps; убраны дубли из дин-список-блока Table
  (useAlternationRowColor/initialTreeView остаются table-specific) и из Emit-Table
  (enableStartDrag).
Зеркало в form-compile.py идентично (py==ps1 проверено).

Валидация: FileDragMode/DefaultItem/EnableStartDrag — 0 LOST / 0 ADDED (полностью
закрыты на всех типах); SkipOnInput 141→37 (остаток — companion/nested-cmdbar кнопки,
редундантный false, в BACKLOG); регресс 33/33 ps+py; сертификация в 1С PASS; harness
8300→7971 (−329), 0 fail, match 7→8.

Spec: defaultItem/enableStartDrag/fileDragMode/skipOnInput → раздел 4.1 (общие свойства).
В BACKLOG: хвост SkipOnInput на companion + мис-атрибуция дубликатов в harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:16:18 +03:00
Nick Shirokov 1d158e3218 feat(form-decompile,form-compile): блок свойств таблицы динамического списка (кластер DynamicList Table)
Таблица формы, привязанная к динамическому списку, несёт блок специфичных свойств,
который платформа всегда эмитит (n=5079 на дин-список-таблицах, 0 на ValueTable).

Компилятор (ps1+py v1.40): авто-эмиссия блока на дин-список-таблице (Emit-DynListTableBlock):
- Group A (дефолт+override): AutoRefresh(false), AutoRefreshPeriod(60), Period(пустой Custom,
  константа), ChoiceFoldersAndItems(Items), RestoreCurrentRow(false), TopLevelParent(nil,
  константа), ShowRoot(true), AllowRootChoice(false), UpdateOnDataChange(Auto),
  AllowGettingCurrentRowURL(true).
- Group B (условные): DefaultItem, UseAlternationRowColor, FileDragMode (+ существующие
  InitialTreeView/EnableStartDrag).
- Group C: RowPictureDataPath (умный дефолт <Список>.DefaultPicture + override + suppress-маркер
  ""; ИСПРАВЛЕН баг — пустая строка больше не перезатирается дефолтом), RowsPicture, UserSettingsGroup.
- Эвристика 11b.4 теперь обходит ВСЕ DynamicList-реквизиты (не только main) → блок эмитится
  и для не-main/вторичных списков. Внутренний маркер _dynList исключён из валидатора ключей.

Декомпилятор (v0.22): захват блока с инверсией (gate = наличие <UpdateOnDataChange>):
Group A — опускает значения = дефолту; Group B — захват при наличии; RowPictureDataPath —
DefaultPicture опускается, кастом захватывается, отсутствие → "".

Валидация: дин-блок round-trip CLEAN (мультимножество, GUID-норм.) на простом списке и на
форме с кастомным RowPictureDataPath/RowsPicture/UserSettingsGroup; сертификация в 1С PASS;
py==ps1 идентичны; регресс 33/33 ps+py; harness 9634→8300 (−14%), 0 fail; группа «67»
(AutoRefresh/ShowRoot/UpdateOnDataChange/…) ушла из остатка, residual block-теги 47→5.

Spec — раздел «Таблица динамического списка»; тест-кейсы перегенерированы. Хвост и соседний
кластер (свойства Table на не-дин-список таблицах) — в BACKLOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:29:30 +03:00
Nick Shirokov 15883a7e7c feat(form-decompile,form-compile): настройки динамического списка — источник, ListSettings, контент filter/order/conditionalAppearance (кластер DynamicList Settings)
Декомпилятор (v0.21): парс <Settings xsi:type="DynamicList"> — mainTable/query/
dynamicDataRead(дефолт true→omit)/fields(только при наличии); вынос query в
<basename>-<имяСписка>.sql рядом с JSON (зеркало skd-decompile); захват контента
ListSettings (filter/order/conditionalAppearance) в skd-грамматику. Пустой/
каноничный скелет опускается (компилятор регенерит).

Компилятор (ps1+py v1.39): query→ManualQuery=true+QueryText (+@file-резолвер);
порядок платформы ManualQuery→DynamicDataRead→QueryText→Field*→MainTable→ListSettings;
прощающий ввод (Справочник.X→Catalog.X через refRootSynonyms; убыв→desc/возр→asc);
каноничный ListSettings-скелет с константными GUID контейнеров (~90% форм бит-в-бит);
эмиттеры filter/order/conditionalAppearance скопированы из skd-compile (навыки автономны).

Валидация: раундтрип CLEAN (бит-в-бит, GUID-норм.) на order/filter/condApp/пустом;
сертификация в 1С PASS (пустой скелет + контент грузятся в базу); py==ps1 идентичны;
регресс 33/33 ps+py; harness 12381→9634 (−22%), 0 fail, LOST контента=0.

Тест-кейс dynamic-list-form дополнен контентом; spec — раздел settings динсписка.
Хвост (минимальный/частичный ListSettings) — в BACKLOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:52:06 +03:00
Nick Shirokov 6857ad5060 fix(form-decompile,form-compile): formatted у LabelDecoration независим от hyperlink (кластер formatted)
Компилятор выводил <Title formatted="…"> из hyperlink (formatted = hyperlink),
но это неверно: атрибут formatted НЕЗАВИСИМ. По корпусу acc+erp:
- 9080 label'ов: hyperlink есть, formatted=false (компилятор давал true);
- 6545: formatted=true без hyperlink (компилятор давал false).
Итого ~15625 расхождений.

Введён отдельный ключ formatted (bool, выводится при true):
- декомпилятор: захват атрибута <Title formatted> у LabelDecoration (независимо
  от <Hyperlink>);
- компилятор Emit-Label: formatted из ключа, не из hyperlink.

Декомпилятор (ps1) + компилятор (ps1+py) + spec (label.formatted). Снэпшот
events обновлён: label с hyperlink:true теперь даёт formatted="false" (фиксирует
развязку) — сертифицирован в 1С 8.3.24. Регресс ps+py 33/33.

Остаток <Title formatted> в раундтрипе принадлежит ExtendedTooltip-с-контентом
и PictureDecoration — отдельные кластеры (в BACKLOG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:35:11 +03:00
Nick Shirokov 908af27bf0 feat(form-decompile,form-compile): tooltipRepresentation элемента (кластер ToolTipRepresentation)
<ToolTipRepresentation> (режим показа подсказки: None/Button/ShowBottom/ShowTop/
ShowLeft/ShowRight/ShowAuto/Balloon) — общее свойство элемента (Button 13785,
Popup 6417, ButtonGroup, InputField, CheckBoxField, LabelDecoration, группы и
др.; None доминирует — 25241). Терялся: декомпилятор не читал, компилятор не эмитил.

Введён общий passthrough-ключ tooltipRepresentation:
- декомпилятор: захват в Add-CommonProps;
- компилятор: эмиссия в Emit-Title (после ToolTip) — покрывает все эмиттеры,
  зовущие Emit-Title; плюс отдельно в Emit-Label (свой title-блок, не зовёт
  Emit-Title).

Декомпилятор (ps1) + компилятор (ps1+py) + spec §4.1. Покрытие: input-fields
(input, ShowBottom), events (label-декорация, Button) — сертифицировано в 1С
8.3.24. Раундтрип БанковскиеСчета/Wildberries: остаток ToolTipRepresentation = 0.
Регресс ps+py 33/33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:02:06 +03:00
Nick Shirokov 22e929ecb3 feat(form-decompile,form-compile): tooltip элемента + фикс экранирования текста (кластер ToolTip)
Два дефекта вокруг текста <v8:content>, оба вскрылись на формах с подсказками.

1. ToolTip элемента (484 LOST в корпусе). <ToolTip> — прямой мультиязычный
   текст подсказки на элементе (UsualGroup 42150, Popup, Page, InputField,
   и почти все типы). Декомпилятор пропускал (как companion), компилятор не
   эмитил. Введён общий ключ tooltip (string|{ru,en}), как title:
   - декомпилятор: захват в Add-CommonProps;
   - компилятор: эмиссия в Emit-Title (сразу после Title) — покрывает все
     эмиттеры, зовущие Emit-Title.
   Попутно выяснилось, что Emit-Pages/Emit-CommandBar вовсе не звали Emit-Title
   (теряли и Title, и ToolTip), а Emit-Label эмитит Title по-своему — во все три
   добавлена обработка title/tooltip.

2. Экранирование кавычек. Esc-Xml экранировал " → &quot; в тексте элемента,
   но 1С в <v8:content> пишет " литерально (экранирует только & < >).
   Это ломало раундтрип любого текста с кавычками. Убрано экранирование " .

Декомпилятор (ps1) + компилятор (ps1+py) + spec (§4.1 tooltip). Покрытие:
input-fields (input+tooltip), pages (pages/page tooltip, page с кавычкой в
тексте — проверяет литеральность) — сертифицировано в 1С 8.3.24. Раундтрип
БанковскиеСчета/Wildberries/АдреснаяКнига: ToolTip и &quot; остаток = 0.
Регресс ps+py 33/33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:36:30 +03:00
Nick Shirokov c43041c0b7 feat(form-decompile,form-compile): TitleLocation у LabelField/PictureField/Table (кластер TitleLocation)
TitleLocation обрабатывался только у input (passthrough), check/radio
(smart-default) и calendar. У LabelField (7362 в корпусе), PictureField (2479)
и Table (381) тег молча терялся — ни декомпилятор, ни компилятор его не знали.

Профиль доли элементов с тегом: Table 2.9%, LabelField 15.8%, PictureField
80.5% (но 20% без тега). Платформа НЕ всегда эмитит → выбран passthrough
(эмитим при наличии ключа, как у input/calendar), не smart-default. Корректно
и консистентно; переиспользован существующий ключ titleLocation + Map-TitleLoc.

Декомпилятор (ps1): захват titleLocation в трёх ветках. Компилятор (ps1+py):
эмиссия в Emit-LabelField/Emit-Table/Emit-PictureField в позиции по схеме.
spec §4.1: titleLocation вынесен в общие свойства с пометкой охвата.

Тест-покрытие добавлено в input-fields (labelField=left), picture-field
(picField=none), table (table=top) — снэпшоты сертифицированы в 1С 8.3.24.
Раундтрип 60 форм с TitleLocation на label/pic/table/radio: остатка нет.
Регресс ps+py 33/33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:33:05 +03:00
Nick Shirokov 8998c0b5db feat(form-decompile,form-compile): свойства CalendarField (кластер CalendarField)
CalendarField терял специфичные свойства при раундтрипе: декомпилятор их не
читал, компилятор не эмитил. Пробел DSL (класс 3). CalendarField — длинный
хвост (18 форм на 17033, 0.1%), но элемент маленький и ограниченный → решено
покрыть целиком, убрав класс молчаливых потерь.

Добавлены ключи (passthrough, эмитятся только при наличии): selectionMode,
showCurrentDate, widthInMonths, heightInMonths, showMonthsPanel. Плюс
подключён общий titleLocation (раньше у календаря не обрабатывался).

Порядок тегов выверен по корпусу (18 форм): DataPath > Title > TitleLocation
> [layout] > SelectionMode > ShowCurrentDate > WidthInMonths > HeightInMonths
> ShowMonthsPanel > companions > Events.

Декомпилятор (ps1) + компилятор (ps1+py) + spec. Новый тест-кейс calendar
(два календаря: со скалярами+событием и с months-panel), сертифицирован
в 1С 8.3.24. Регресс ps+py 33/33.

Tooltip-свойства календаря (ToolTip/ToolTipRepresentation) намеренно оставлены
будущему общему tooltip-кластеру. Раундтрип календарных форм: ПериодКомандировки
→ match; остаточный TitleLocation на radio/table — отдельная находка (BACKLOG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:08:09 +03:00
Nick Shirokov 4c2c72abce feat(form-decompile,form-compile): унификация событий элементов на events-мапу (кластер Events DSL)
Несогласованность DSL: события ФОРМЫ описывались интуитивной мапой
events:{Событие:Обработчик}, а события ЭЛЕМЕНТА — двумя сущностями
on:[...] + handlers:{...}. Два способа для одного понятия путали модель.

Унифицировано на единую мапу events:{Событие:ИмяОбработчика} на форме И
элементах (как form-level). Декомпилятор эмитит только её, с явными именами
обработчиков (прозрачно, консистентно с form-level).

Компилятор (ps1+py):
- Emit-Events читает events-мапу (основной формат); значение null/"" →
  имя по конвенции ИмяЭлемента+суффикс (прощающий fallback).
- legacy on/handlers по-прежнему принимаются ради совместимости (не эмитятся).
- choiceButton: проверка StartChoice через оба формата (Test-ElementEvent).
- events добавлен в whitelist ключей элемента.

Декомпилятор: Get-Events → упорядоченная мапа {Событие:Обработчик} в порядке
документа; убраны on/handlers и инверсия авто-имён.

spec/SKILL.md: events как единственный рекомендованный формат, on/handlers
помечены legacy. В SKILL.md только явные имена (null-сахар — деталь spec,
инструкцию не раздуваем).

Корпус acc_8.3.24: 190 элементов в 114/400 форм теряли Events до фикса (баг
on/handlers разобран отдельным коммитом). Раундтрип 2.17: Events ушли из топа
LOST, match 4→6, 0 compile-fail. Регресс ps+py 32/32, снэпшот events (добавлен
блок Events у поля с переименованным обработчиком) сертифицирован в 1С 8.3.24.

Follow-up: form-edit использует расширенный on с {event,callType} —
унификация отдельным решением (см. BACKLOG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:46:56 +03:00
Nick Shirokov a38874280c feat(form-decompile): события с кастомными именами в on (кластер Events handlers-only)
Get-Events клал в on только авто-именованные обработчики, а кастомные
(переименованные или без суффикса: OnActivateDate, ValueChoice, Selection
с нестандартным именем…) — ТОЛЬКО в handlers, минуя on. Компилятор итерирует
по on → такие события не эмитились вообще. Корпус acc_8.3.24: 190 элементов
в 114 формах из 400 теряли Events.

Контракт DSL (spec §4.1/4.2): on = полный список имён событий, handlers =
переопределение имени. Декомпилятор нарушал его — чистый баг класса 1,
компилятор корректен. Чиним: каждое событие → в on (порядок документа),
handlers только для не-авто имён.

Порядок важен: в корпусе 1956 <Events>-блоков, где кастомное событие идёт
перед авто (паттерн не A*C*). Поэтому union в компиляторе (on, затем handlers)
дал бы неверный порядок — единственная упорядоченная структура DSL это on,
её и заполняем полностью.

Зеркало PY компилятора не нужно (правка только декомпилятора). Валидация:
ПериодКомандировки/Банки/АктивныеПользователи — Events ушли из диффов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:54:36 +03:00
Nick Shirokov bccdf93094 feat(form-decompile): form-level <CommandSet>/ExcludedCommand (кластер ExcludedCommand)
Декомпилятор читал <CommandSet> только внутри Table-элемента; на корне формы
(<Form><CommandSet><ExcludedCommand>…) пропускал → excludedCommands терялись
(LOST=207 в корпусе 2.17). Компилятор и DSL уже поддерживали excludedCommands
на top-level — чистый баг декомпиляции (класс 1).

Читаем корневой CommandSet в $dsl["excludedCommands"], порядок по spec
(title → properties → excludedCommands → events). Зеркало PY не нужно
(decompiler.py отложен), корпус не нужен (читаем поддерживаемую фичу).
Валидация: ФормаЗаписи + ФормаЭлемента раундтрип diff→match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:38:15 +03:00
Nick Shirokov d935cfe0cc test(form-compile): дозакоммит CheckBoxType=Auto в item-снапшоты from-object (хвост кластера L)
В коммите кластера L (b4fc9bf) git add не охватил cases/form-compile-from-object/snapshots/,
из-за чего 3 item-снапшота с флажками остались с устаревшим выводом (без CheckBoxType).
Только добавление <CheckBoxType>Auto</CheckBoxType>. Регресс 32/32 PS1+PY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:05:24 +03:00
Nick Shirokov 3483802ab0 docs(form-dsl-spec): уточнение семантики "" — суппресс-маркер, не «дефолт платформы»
"" означает «не выводить тег» (платформа применит своё рантайм-умолчание),
а не «значение = дефолту платформы». Разведение дефолта эмиссии и дефолта рантайма.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:02:38 +03:00
Nick Shirokov 3b0061c8a0 feat(form-decompile,form-compile): мультиязычный текст (кластер I)
БАГ: Emit-MLText стрингифицировал мультиязычный объект {ru,en} →
<v8:content>@{ru=…; en=…}</v8:content> (мусор). ERP — двуязычная конфигурация,
поэтому это доминирующий пробел раундтрипа (item/content/lang).

- compiler PS1+PY: Emit-MLItems/emit_ml_items — по <v8:item> на язык; все
  вызывающие (Title/ToolTip/InputHint/реквизиты/колонки/команды/форма + Emit-Label)
  передают сырой объект вместо стрингификации. choice presentation уже был мультиязычен.
- decompiler уже давал {ru,en}; убран мёртвый titleFormatted (компилятор выводит formatted из hyperlink).
- docs/form-dsl-spec: title/tooltip/inputHint принимают объект {ru,en,…}.
- tests: groups (title {ru,en}) сертифицирован в 1С.

Эффект (220 форм 2.17): item 3909→1475, content 3193→737, lang 1861→635. Регресс 32/32 PS1+PY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:28:53 +03:00
Nick Shirokov b4fc9bf42c feat(form-decompile,form-compile): листовые свойства полей + фикс Hiperlink (кластер L)
БАГ: у LabelField платформенный тег <Hiperlink> (опечатка 1С), компилятор
эмитил <Hyperlink> — гиперссылка не работала и не роундтрипилась. Проверено
по корпусу: LabelField→Hiperlink во всех версиях формата (2.17 и 2.20).

- compiler PS1+PY: LabelField <Hiperlink>; EditMode (input/check/labelField);
  CheckBoxType (check, умный дефолт Auto + suppress как radioButtonType).
- decompiler: editMode, checkBoxType (Auto→опустить), markIncomplete (раньше не ловился),
  labelField читает <Hiperlink>.
- docs/form-dsl-spec: editMode, checkBoxType, примечание про Hiperlink.
- tests: input-fields расширен (editMode/checkBoxType/labelField+hyperlink), сертифицирован.

Регресс 32/32 PS1+PY, churn по флажкам обновлён и сертифицирован.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:29:31 +03:00
Nick Shirokov f27a17139a feat(form-compile): AdditionSource табличных additions (кластер C)
У всех таблиц SearchStringAddition/ViewStatusAddition/SearchControlAddition
несут <AdditionSource> (Item = имя таблицы, Type фиксирован по виду). Раньше
компилятор эмитил их пустыми self-closing.

- compiler PS1+PY: Emit-TableAddition/emit_table_addition — addition с
  AdditionSource + вложенными ContextMenu/ExtendedTooltip.
- В DSL ничего не добавлено: чистое авто-обогащение (модель объявляет таблицу
  → корректные элементы поиска генерируются сами). decompiler/spec не тронуты.

Эффект на раундтрип: AdditionSource ушёл из LOST; ContextMenu 100→7,
ExtendedTooltip 210→124 (каскад схлопнут). Регресс 32/32 PS1+PY,
12 снапшотов обновлены и сертифицированы в 1С (20/20).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:31:44 +03:00
Nick Shirokov 9c1ea1662a feat(form-decompile,form-compile): CommandSet/excludedCommands таблиц (кластер F)
CommandSet встречается на Table (23) и Form (8). Форменный excludedCommands
уже поддержан, табличный — нет.

- compiler PS1+PY: Emit-Table — excludedCommands → <CommandSet>; заодно
  viewStatusLocation/searchControlLocation (из того же блока свойств таблицы).
- decompiler: Table — CommandSet→excludedCommands, searchStringLocation
  (раньше не ловился), viewStatusLocation/searchControlLocation.
- docs/form-dsl-spec: excludedCommands + view/searchControl у таблицы.
- tests: table расширен, сертифицирован в 1С.

ExcludedCommand ушёл из топа LOST. Регресс 32/32 PS1+PY, churn нулевой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:18:17 +03:00
Nick Shirokov 0941fc717d feat(form-decompile,form-compile): семантика TitleLocation (кластер G2)
Принцип: компилятор не эмитит значение, равное дефолту платформы (который
платформа сама не пишет в XML). Умный дефолт (check→Right, radio→None) —
отдельная вещь, эмитится (он ≠ дефолт платформы Left).

- net ключа titleLocation → умный дефолт; titleLocation: "" → подавить
  (дефолт платформы); значение → эмитить с маппингом регистра.
- compiler PS1+PY: Emit-TitleLocation/emit_title_location + Map-TitleLoc
  (общий маппинг; у check раньше его не было — сырьё).
- decompiler: Add-TitleLocation (дефолт → опустить, нет тега → "", иначе значение).
- docs/form-dsl-spec: семантика titleLocation у check/radio.
- tests: input-fields расширен (Right-дефолт / ""-подавление / явный Top), сертифицирован.

АварийныйРежим: полный MATCH. Регресс 32/32 PS1+PY, churn нулевой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:53:28 +03:00
Nick Shirokov 4ba1e595bf feat(form-decompile,form-compile): семантика title (кластер G)
Над-генерация заголовков элементов из имени. Различаем:
- нет ключа title → авто-вывод из имени (помощь модели при создании форм);
- title: "" → подавить (<Title> не эмитим);
- непустая строка → как есть.

- compiler PS1+PY: Emit-Title/emit_title + Emit-Label проверяют наличие ключа,
  а не truthiness (раньше "" триггерило авто-вывод).
- decompiler: ставит title:"" для авто-выводящих типов (page/popup/label,
  непривязанные поля, button без команды), когда <Title> в оригинале отсутствует.
- docs/form-dsl-spec: семантика title.
- tests: pages демонстрирует title:"" (+snapshot, сертифицирован в 1С).

АварийныйРежим: diff 13→1. Регресс 32/32 PS1+PY, churn нулевой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:32:02 +03:00
Nick Shirokov e777ded8d2 feat(form-decompile,form-compile): геометрия/layout единым хелпером (кластер E)
- compiler PS1+PY: общий Emit-Layout/emit_layout (width/height/stretch/maxWidth/
  maxHeight/autoMax*/skipOnInput/groupHorizontalAlign/groupVerticalAlign/
  horizontalAlign), вызывается во всех эмиттерах; inline-дубли убраны. Спец-квирки
  сохранены (input multiLine→autoMaxWidth, table height→HeightInTableRows).
- PictureDecoration LoadTransparent больше не захардкожен true — управляется
  loadTransparent (дефолт false).
- decompiler: Add-Layout (DRY, один вызов на элемент), table HeightInTableRows,
  picture loadTransparent.
- docs/form-dsl-spec: блок общих layout-свойств (4.1a), loadTransparent у picture.
- tests: groups расширен layout-свойствами (+snapshot, сертифицирован в 1С).

Churn снапшотов нулевой. АварийныйРежим: LOST полностью закрыт (остаток — над-генерация).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:51:13 +03:00
Nick Shirokov 67eaa1c3c8 feat(form-decompile,form-compile): командные панели (кластер D)
- form-decompile: форменный AutoCommandBar → autoCmdBar-элемент; ButtonGroup;
  команды tooltip/currentRowUse; choiceList value type (число/булево) — раньше.
- form-compile (PS1+PY): новый тип ButtonGroup; команды ToolTip/CurrentRowUse.
- docs/form-dsl-spec: buttonGroup, autoCmdBar (панель формы), tooltip/currentRowUse.
- tests: кейс form-compile/button-group (+snapshot, платформенно валидирован).

АварийныйРежим раундтрип: diff 44→18. Регресс form-compile зелёный (PS1+PY).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:22:07 +03:00
Nick Shirokov 7c38422e2c feat(form-decompile): MVP-декомпилятор Form.xml→JSON + компактный вывод (draft, ring-ограничен)
Декомпилятор управляемой формы в формат form-compile (инверсия компилятора):
метаданные, реквизиты, параметры, команды, события, рекурсия ChildItems по
базовым типам, strip companions, инверсия типов и авто-имён обработчиков.
Компактный вывод тем же сериализатором, что skd-decompile (inline в пределах
lineLimit=120). Ring-ограничен (disable-model-invocation): раундтрип не
гарантируется, риск неполноты на пользователе.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:37:54 +03:00
Nick Shirokov 6d119eb473 feat(skd-edit): значение-список параметра в шортхенде (+skd-compile)
Значение по умолчанию у параметра СКД может быть списком (несколько <value>
подряд при valueListAllowed=true). Раньше задать список можно было только через
объектную модель skd-compile; шортхенд (add/modify-parameter, parameters) парсил
value= как скаляр.

Теперь в шортхенде: value=v1, v2, v3 задаёт список (кавычки '...' для запятой
внутри значения). Если задан список (>=2 элементов), valueListAllowed выводится
автоматически. Авто-вывод только в шортхенде — объектная модель остаётся
буквальной (bit-perfect round-trip сохранён).

skd-edit (ps1+py v1.25):
- Split-QuotedCsv/Parse-ValueList — токенайзер по запятым с учётом кавычек, БЕЗ
  разреза по ':' (важно для дат вида 2024-01-01T12:30:45)
- add-parameter: эмит N <value>
- modify-parameter: пред-выемка value=-списка, удаление ВСЕХ старых <value>,
  авто valueListAllowed; scalar value= теперь тоже схлопывает список в один <value>

skd-compile (ps1+py v1.105): тот же разбор списка в Parse-ParamShorthand;
объектная модель не тронута.

Документация: skd-edit/skd-compile SKILL.md (поведение), docs/1c-dcs-spec.md и
docs/skd-dsl-spec.md (формат).

Тесты: add-list, modify list<->scalar, список дат (двоеточия целы), compile-
шортхенд. Полный регресс 413/413 на ps1 и py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:26:57 +03:00
Nick Shirokov 9877fe403a feat(skd-info): флаг -Raw для lossless round-trip извлечения запроса
skd-info -Mode query был просмотрщиком (заголовки, оглавление батчей,
разделители --- Batch ---) и терял разделители пакетов при split, поэтому
не годился как источник для skd-edit set-query @file.

Флаг -Raw отдаёт текст запроса целиком, verbatim, без декораций и без
дробления на пакеты — все ; и //// на месте. С -OutFile пишет чистый .sql,
который без потерь возвращается через set-query @file. Stdout не усекается
по -Limit. Версия v1.6 в обоих скриптах (ps1 + py).

Документация: таблица параметров/режимов и round-trip workflow в skd-info,
указатель + разводка patch-query vs set-query+-Raw в skd-edit.

Тесты: query-raw (raw без декораций, разделитель //// сохранён) и query-view
(просмотр не задет). Зелёные на ps1 и py.

Чистка: удалён modes-reference.md — галерея примеров вывода избыточна для
модели (инструмент самодемонстрирующийся), а человек покрыт docs/skd-guide.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:11:48 +03:00
Nick Shirokov 46ee078343 docs(web-test): актуализация контракта test CLI (несколько путей, --url)
Спека §1, regress.md, README приведены в соответствие новому контракту:
сигнатура `test <dir|file>...`, несколько путей (дедуп + сортировка), флаг
--url=, заметка про резолв webtest.config.mjs/_hooks.mjs от каталога первого пути.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:07:09 +03:00
Nick Shirokov 7f2bf9d2d3 feat(web-test): test CLI принимает несколько путей + флаг --url, fail-fast валидация
Привод контракта `run.mjs test` к общей практике (pytest/jest/playwright):
- позиционные аргументы = пути к тестам, можно несколько
  (`test a.test.mjs b.test.mjs dir/`); discoverTests объединяет, дедуплицирует,
  сортирует (порядок по числовым префиксам сохраняется);
- URL переехал из первого позиционного во флаг --url= (по умолчанию из
  webtest.config.mjs) — раньше url-первым путал: второй путь уходил в page.goto()
  и падал с криптовым 'Cannot navigate to invalid URL';
- fail-fast: несуществующий путь → понятная ошибка вместо падения goto;
  аргумент, похожий на URL, подсказывает про --url=.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:07:03 +03:00
Nick Shirokov 31fa66d8fe test(web-test): регресс на readSpreadsheet до Сформировать + object-search selectValue
- 11-report: чтение несформированного отчёта бросает осмысленную ошибку,
  не ReferenceError (покрывает import checkForErrors в readSpreadsheet);
- 04-selectvalue: объектный поиск selectValue({Наименование}) выбирает через
  форму выбора (покрывает 3A guard + import filterList).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:41:06 +03:00
Nick Shirokov a8e61d02a2 fix(web-test): починка объектного поиска selectValue({field: value})
Три проблемы объектного поиска (следствие рефакторинга на модули):
- импорт filterList отсутствовал — pickFromSelectionForm (Шаг 2) бросал
  ReferenceError, который молча глотался catch'ем, поиск по полю не работал;
- dropdown-путь 3A падал на searchText.toLowerCase() (объект, не строка) —
  теперь объектный search уходит в форму выбора, где обрабатывается per-field;
- сужен catch вокруг filterList: ReferenceError/TypeError пробрасываются,
  чтобы будущие missing-import не маскировались как 'поле не найдено'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:41:00 +03:00
Nick Shirokov c8c0c48ead fix(web-test): импорт checkForErrors в readSpreadsheet
readSpreadsheet() в ветке allCells.size===0 (отчёт не сформирован) вызывал
checkForErrors() без импорта — падало с 'checkForErrors is not defined'
вместо осмысленного сообщения. Следствие рефакторинга на модули.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:40:52 +03:00
Nick Shirokov f1b61b9e9e test(web-test): фокус-клик по полю вместо fillFields для сброса viewport в 18-cell-click
Шаг focus-click пропуска чекбоксов выводил фокус из ТЧ через fillFields({Комментарий}),
что лишний раз перезаписывало значение. clickElement по полю «Комментарий» фокусирует
его без перезаполнения и так же сбрасывает горизонтальный viewport грида. Поведение
шага не меняется (читаются только булевы Товаров), тест зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:47:18 +03:00
Nick Shirokov 9774b8f1c3 fix(web-test): fillTableRow распознаёт переформатированные число/дату в choice-ячейке
fillChoiceCell определял «прижился ли paste» через normYo(after).includes(text),
что ломалось на маск-инпутах: число/дата переформатируются (1234.56 → «1 234,56»,
группировка неразрывным пробелом, запятая) → includes давал false → ложный уход
в F4, где у числа открывался калькулятор и залипал (no_selection_form).

Заменил на поведенческий дискриминатор: появился EDD → ссылка (dropdown);
инпут изменился на непустое без EDD → редактируемая ячейка (direct); инпут
не изменился → НачалоВыбора → F4-форма. + страховка: если F4 открыл не форму
выбора (калькулятор/календарь) — Escape и спасение значения.

Также в EDD-ветке основного Tab-цикла убран слепой fallback items[0]: при
отсутствии exact/includes-совпадения возвращается not_found с очисткой поля,
а не подставляется произвольная первая запись автокомплита.

Регресс: в стенд (дерево) добавлены choice-колонки Число/Дата и булево-поле-ввода;
в 16-tree-form — шаги choice-number/choice-date/bool-input. Полный регресс: 22 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:42:34 +03:00
Nick Shirokov c147fd5cb7 feat(web-test): fillTableRow редактирует строку по фильтру { col: value } + scroll
fillTableRow теперь принимает row как объектный фильтр (одна/несколько колонок,
AND-матч) — как clickElement — и опцию scroll:true для строк за пределами
DOM-окна виртуализации. Фильтр резолвится в числовой индекс один раз в начале
через переиспользование resolveRowIndexByFilter из click-cell.mjs (без дублей
matching/reveal); дальше существующий код row-mode не тронут. row:<число> —
полная обратная совместимость.

Побочно починен баг в общем reveal-цикле (его же использует clickElement scroll):
детектор конца списка опирался на текст первой колонки + selIdx, поэтому на
табчасти с однотипной первой колонкой ложно срабатывал на втором PageDown.
Теперь основной признак конца — hasBelow===false, а сигнатура снимка строится
по всей строке (snapshotGridScript).

Версии: click-cell v1.4, dom/grid v1.9, row-fill v1.22.
Регресс tests/web-test: 22/22 зелёные (live E2E на синтетическом стенде).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:03:06 +03:00
Nick Shirokov ffb380187f feat(web-test): exact-match при выборе типа в pickFromTypeDialog
Диалог выбора типа матчил по подстроке и падал «multiple types match»,
даже когда точное совпадение присутствовало в выдаче (напр. поиск
«Контрагент» давал «Банковская карта контрагента», «Договор с контрагентом»,
…, «Контрагент» — и движок ругался, хотя точная строка была видна).

pickFromTypeDialog теперь предпочитает точное совпадение (resolveExact:
единственный матч, либо единственная строка, равная искомому имени после
нормализации регистра/ё) — кликает именно её и жмёт OK. Применяется и в
scan-пути (мелкие списки), и после Ctrl+F (большие виртуальные списки).
Добавлен ограниченный скролл-скан (PageDown ×3) на случай, когда точная
строка чуть ниже первого окна. Ошибка неоднозначности остаётся, только если
единственного точного совпадения действительно нет.

Стенд: в СписокТипов добавлен подстрочный дубль «Дата документа» рядом с
«Дата» для детерминированной проверки exact-match. Тест 16-tree-form
покрывает scan-путь (выбирается точное «Дата»).

Проверено: регресс web-test 22/22, живой E2E на типовой Консоли запросов
(ссылочный тип через Ctrl+F + примитив без регресса).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:00:46 +03:00
Nick Shirokov 80ffed9a28 feat(web-test): fillTableRow заполняет редактируемую ячейку-выбор прямым вводом
Ячейка грида с кнопкой выбора (iCB, buttonKind:'choice') бывает двух видов,
неразличимых в DOM (оба editInput, readOnly:false): редактируемое значение
(текст прилипает) и выбор из программного списка (РедактированиеТекста=Ложь —
текст отвергается, readOnly при этом не выставляется). Движок жал F4 на обе и
падал no_selection_form, если форма не открывалась.

Новый общий helper fillChoiceCell различает их поведенчески: пробует прямой
ввод, и если вставленный текст прилип — коммитит (method:'direct'), иначе
открывает форму по F4 (isTypeDialog → pickFromTypeDialog 'choice', иначе
pickFromSelectionForm 'form'). Вызывается из обоих мест (плоский Tab-цикл и
tree direct-edit) — плоский и tree гриды теперь ведут себя одинаково.

Стенд: ДеревоТипЗначения получает textEdit:false (модель выбора-из-списка),
добавлено поле ДеревоРедактируемаяСтрока (кнопка выбора + пустой НачалоВыбора,
модель редактируемой ячейки). Тест 16-tree-form покрывает оба плеча.

Проверено: полный регресс web-test 22/22, живой E2E на типовой Консоли запросов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:14:36 +03:00
Nick Shirokov 1106117e33 test(skd-decompile): реалистичная структура в фикстуре auto-group (Период > Auto)
Фикстура dataset-folder-and-auto-group задавала «Auto > Период» — «Авто»-поле
группировки родителем явной группировки по Период. В типовых ERP/БП такого нет:
GroupItemAuto встречается (13 макетов против 753 у GroupItemField), но всегда как
настраиваемый ЛИСТ (с явной выборкой, обычно viewMode=Inaccessible), а не родителем.

Структура заменена на shorthand «Период > Auto» (группировка по Период, внутри «Авто»):
- идиоматично, GroupItemAuto остаётся покрытым (единственная фикстура с ним);
- shorthand-форма даёт Template.xml с auto-полями (как платформа), поэтому
  round-trip снова bit-perfect (object-form без selection/order их не эмитил).

Проверено: платформа принимает (ERF + epf-build), round-trip bit-perfect,
decompile 16/16 на PS и PY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:41:56 +03:00
Nick Shirokov e03ba3b509 fix(skd-decompile): сворачивать дефолтные auto selection/order группы в shorthand
После 6781bb3 компилятор кладёт в каждую группу структуры авто-поле выбора и
авто-порядок (SelectedItemAuto/OrderItemAuto), как делает платформа. decompile
сохранял их как selection:["Auto"]/order:["Auto"], из-за чего Try-StructureShorthand
не сворачивал цепочку и выдавал громоздкую объектную модель вместо строки
"A > B > details".

Теперь selection/order, состоящие ровно из одного "Auto" (предикат Is-AutoOnly /
is_auto_only), считаются дефолтом и не мешают свёртке. Round-trip снова bit-perfect:
shorthand перекомпилируется в идентичный XML (Parse-StructureShorthand сам добавляет
эти auto-поля). Отключённый auto ({auto,use}), смешанные списки и явные поля свёртку
не проходят и остаются в объектной форме.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:12:50 +03:00
Nick Shirokov 3d6a09e90a test(skd): синхронизировать снапшоты с выводом skd-compile
Эталоны skd-info/skd-validate/skd-edit/skd-decompile «вшивают» Template.xml,
генерируемый skd-compile в preRun, но отстали от изменений компилятора:
- 11ddc2b — single-line эмиссия <DataCompositionSchema xmlns=...>
- 6781bb3 — авто-выборка/порядок (SelectedItemAuto/OrderItemAuto) в группах

Регенерированы под текущий вывод. Платформенно сертифицировано через
verify-snapshots: skd-compile 23/23, skd-edit 45/45 (ERF + epf-build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:12:28 +03:00
Nick Shirokov b188d338f9 feat(meta-info): выводить «Представление типа» для ссылочных объектов
В шапке ссылочных объектов (справочники, документы, перечисления, ПВХ/ПВР,
планы счетов, планы обмена, бизнес-процессы, задачи) теперь выводится строка
«Представление типа» — имя ссылочного типа в диалогах выбора типа, с fallback
ObjectPresentation -> Synonym -> Name. В режиме full дополнительно выводятся
заданные сырые представления (объекта/списка и расширенные).

Тесты: раннер принимает stdoutContains строкой или массивом, добавлен
stdoutNotContains. Добавлены кейсы meta-info (ед.ч. ПВХ, full со всеми
представлениями, fallback на синоним) и негативная проверка у регистра.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:40:17 +03:00
Nick Shirokov 7c9769c644 feat(web-test): fillTableRow заполняет ячейку-выбор-из-списка через форму выбора
Поле с кнопкой выбора и обработчиком НачалоВыбора (значение выбирается из программного
списка — например колонка Тип в типовой Консоли запросов) раньше заполнялось plain-paste,
который молча откатывался → ok:true/method:direct (ложный успех). Теперь движок детектит
такую ячейку и выбирает значение из формы выбора.

- dom/grid-edit.mjs: readActiveGridCellScript отдаёт buttonKind активной ячейки
  (ref/calc/date/choice по кнопке _DLB/_CB и её классу).
- engine/table/row-fill.mjs v1.20: для kind=choice — F4 → pickFromTypeDialog
  (скан/Ctrl+F/OK) → method:choice; если после выбора открылась форма значения,
  это составная ячейка (нужен {value,type}). Ветка добавлена в Tab-цикл и directEditPick.
- engine/forms/select-value.mjs v1.21: умный dismiss диалога типов на путях
  not_found/multiple — Escape только пока диалог открыт, больше не закрывает
  исходную форму слепым Escape×3.
- Стенд: строковая колонка-выбор ТипЗначения (НачалоВыбора → ПоказатьВыборЭлемента)
  в ДеревоНоменклатуры; тест 16 покрывает method:choice и негатив not_found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:26:37 +03:00
Nick Shirokov 52478a6c39 fix(form-compile): эмитить <ChoiceButton>true</ChoiceButton> при choiceButton:true + StartChoice
Компилятор выводил тег <ChoiceButton> только для значения false; при choiceButton:true
он не эмитился, и у нессылочного поля (например строкового с обработчиком НачалоВыбора)
кнопка выбора не отрисовывалась — документированный паттерн (SKILL.md: choiceButton:true
+ on:['StartChoice']) фактически не работал.

Теперь true эмитится, но узко: только когда у поля есть обработчик StartChoice — чтобы
не раздувать вывод по ссылочным полям (у них choiceButton=true стоит по умолчанию,
а кнопка платформенная). Порты ps1+py синхронны. Снапшот file-dialog обновлён,
31/31 кейс зелёные на обоих портах.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:26:23 +03:00
Nick Shirokov ebdd596d4f fix(web-test): числовое поле с калькулятором (iCalcB) заполнять paste, не selectValue
fillFields классифицировал поля по кнопкам: _DLB → ссылка, _CB → pick (если
класс iCalendB → дата). Числовое поле формы (напр. «Цена») имеет _CB с классом
iCalcB (калькулятор) и isDate=false, поэтому уходило в ветку selectValue, которая
ждёт форму выбора → детерминированный фейл "DLB click did not open a popup or
selection form". Калькулятор формой выбора не является.

- dom/forms.mjs: распознаём iCalcB → флаг isCalc (по аналогии с isDate/iCalendB),
  пробрасываем его в resolveFieldsScript.
- engine/forms/fill.mjs: ветку paste расширяем на hasPick && (isDate || isCalc) —
  калькулятор заполняем через Ctrl+A + paste + Tab, как календарь. Ссылочный
  fallback (hasPick без даты/калькулятора) не тронут.

Пробел покрытия: «Цена» в наборе заполнялась только через fillTableRow (Tab-путь),
а fillFields-ветка калькулятора не гонялась. Добавлен 'Цена' в 03-fillfields.test
с assert method=paste и значением 777,00. E2E: тест 03 зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:08:14 +03:00
Nick Shirokov 0dde66e2eb fix(web-test): не держать event loop висячим таймером таймаута после теста
Guard-таймаут теста собирался через Promise.race([t.fn, setTimeout(reject)]),
но setTimeout после победы теста не очищался. На успешном пути раннер не зовёт
process.exit(), поэтому node не мог завершиться, пока сторож не догорит — до
`timeout` мс простоя после последнего теста (на стенде с timeout=60s это ~45с
зависания уже после закрытия браузера).

Оборачиваю гонку в try/finally с clearTimeout. Вердикт теста и таймаут-защита
не меняются: clearTimeout срабатывает только после завершения гонки (тест
добежал или сторож сработал), по уже сработавшему таймеру это no-op. Замер на
одиночном тесте: 64.5s → 18.9s wall-clock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:38:56 +03:00
Nick Shirokov 547f336cf8 feat(web-test): test-раннер пишет человеческий отчёт в stdout, JSON по --report=-
Команда `test` приведена к поведению тест-раннеров (jest/pytest/playwright):
человеческий отчёт со сводкой в последней строке идёт в stdout, а машинный
JSON/JUnit — опционально через `--report=-` (Unix-конвенция `-` = stdout),
при этом прогресс уезжает в stderr. Убран безусловный дамп JSON в stdout,
из-за которого `test … | tail` хоронил сводку под отчётом.

- test.mjs: writer выбирается по режиму (--report=- → stderr-прогресс);
  развилка `-` в обеих ветках записи (json и junit), чтобы не плодить файл "-";
  валидация: --report=- несовместимо с --format=allure (каталог, не поток).
- util.mjs: строка --report=- в справке.
- Документация (spec/guide/regress/README) приведена к фактическому
  английскому выводу и описывает матрицу потоков stdout/stderr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:18:12 +03:00
Nick Shirokov f424d2ac70 feat(web-test): фокус на поле ввода через clickElement (fallback)
clickElement как последний fallback (без table) фокусирует одноимённое
поле ввода, не меняя значение — возвращает focused:{field,id,ok}.
Закрывает пробел: клавиши F4/Shift+F4 требовали сфокусированного поля,
но штатного примитива фокуса не было.

- dom/forms.mjs: резолв input.editInput/textarea по имени/заголовку
  последним шагом findClickTargetScript; имена полей в available
- click-form.mjs: focusFormField (клик по инпуту + isInputFocused → ok)
- click.mjs: ветка диспетчера kind === field
- SKILL.md + docs/web-test-guide.md: focused в extras, пример focus→F4
- tests: 19-focus-field.test.mjs (focus/F4/регресс/негатив)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:02:12 +03:00
Nick Shirokov 3a89aa21e6 docs: картиночные колонки readTable + valuesPicture в form DSL
- web-test-guide: раздел про picture-колонки readTable (pic:N/'',
  truthy-наличие, именование по тултипу, read/assert-only — не селектор).
- form-dsl-spec: ключи valuesPicture/loadTransparent у picField.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:52:07 +03:00
Nick Shirokov 7de2689c18 test(web-test): картиночная колонка в стенде ДеревоНоменклатуры
Регресс-покрытие picture-колонок readTable на синтетическом стенде
(без зависимости от реальных баз). В обработку ДеревоНоменклатуры:
- булева колонка Картинка + PictureField (ValuesPicture=StdPicture.Favorites,
  loadTransparent) — иконка у позиций Цена>1000;
- CheckBoxField Флаг на тот же булев (кросс-проверка состояния);
- Selection-обработчик ДеревоВыбор — инверсия по двойному клику.

16-tree-form: обновлён deepEqual колонок (+Картинка +Флаг), добавлены шаги
presence/кросс-проверка (pic:0 ⟺ флаг) и Selection-toggle. Полный регресс
web-test зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:51:02 +03:00
Nick Shirokov 96926d65ef feat(form-compile): ValuesPicture для PictureField
PictureField, привязанный к булеву/числу, без ValuesPicture не рисует
иконку. Добавлены ключи DSL:
- valuesPicture: ref картинки значения (StdPicture.*, CommonPicture.*)
  → <ValuesPicture><xr:Ref>…</xr:Ref></ValuesPicture>
- loadTransparent: true → <xr:LoadTransparent>true</xr:LoadTransparent>
  (выводится только при true)

Реализовано в обоих портах (ps1 + py, v1.22), добавлены в whitelist
свойств. Регресс: новый кейс picture-field (picField + ValuesPicture +
CheckBoxField + событие Selection), эталон зелёный на ps1 и py, плюс
платформенная form-validate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:10:36 +03:00
Nick Shirokov f2b8ad741e feat(web-test): распознавание колонок-картинок в readTable
readTable теперь отдаёт картиночные ячейки как 'pic:<N>' (есть иконка)
или '' (нет): N — индекс кадра спрайта, кодирующий состояние. Присутствие
читается как truthy, разные иконки различаются по индексу. Безымянные
картиночные колонки (напр. индикатор присоединённых файлов) больше не
выпадают — именуются по title-тултипу, fallback '(picture)'.

- dom/grid.mjs: helper picInfo (парсинг gx из pictureCollection url,
  исключение декоративных иконок дерева/групп); ветка пустого заголовка
  добавляет картиночные колонки; resolveCol резолвит колонку по тексту И
  по title (клик по картиночной колонке).
- click-cell.mjs: fail-fast при попытке отбора строки по 'pic:N' —
  понятная ошибка вместо row_not_found (картинки read/assert-only).
- SKILL.md: компактный раздел про картиночные колонки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:25:45 +03:00
Nick Shirokov 89b109ab04 test(web-test): покрыть reveal-loop и hasMore на динамическом списке
3 новых шага в 18-cell-click на группе БольшойСписок (60 элементов)
справочника Номенклатура:

- dyn-list setup — вход в группу, проверка hasMore = {above:false, below:true}
  (определяется через turn-кнопки vertButtonScroll, не через scrollbar табчасти)
- dyn-list reveal — clickElement({row:{filter}}, {scroll:true}) на дин-списке,
  находит Позиция 055 через PageDown loop; после прокрутки above=true
- dyn-list cleanup

Раньше reveal-loop и hasMore проверялись только на табчасти LongDoc;
теперь покрыт и второй тип виртуализированного грида.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:54:42 +03:00
Nick Shirokov 81596503e8 test(web-test): группа БольшойСписок (60 элементов) для дин-список сценариев
Справочник Номенклатура: третья группа БольшойСписок с 60 элементами
(Позиция 001..060) — заведомо больше окна виртуализации (~22-30 строк).
Нужна для тестов reveal-loop и hasMore.above/below на ДИНАМИЧЕСКОМ списке
(до этого длинный список был только в табчасти LongDoc).

Группы Товары (15) и Услуги (10) оставлены как есть — существующие тесты
(05/06/12) полагаются на то, что обе помещаются в DOM-окно.

08-hierarchy и 16-tree-form обновлены под 3 группы верхнего уровня
(было жёстко зашито 2): проверяют наличие всех трёх + БольшойСписок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:54:30 +03:00
Nick Shirokov e36544c1c7 feat(web-test): hasMore.above для динамических списков через turn-кнопки
Раньше для динамических списков (catalog/journal/register) определялся
только hasMore.below (через scrollH>clientH). Направление выше было
неопределимо потому что у дин-списков нет видимого scrollbar widget'а.

Однако у большинства дин-списков 1С рендерит панель пагинации
#vertButtonScroll_<gridId> (сосед грида) с 4 кнопками: data-home
(в начало), data-up (предыдущая страница), data-down (следующая),
data-end (в конец). Класс "disabled" на кнопке = направление недоступно.

readTableScript и snapshotGridScript теперь сначала смотрят на эти
кнопки (если виджет видим), и только потом фолбачатся на scrollbar
tracks для табчастей и scrollHeight для редких случаев без обоих
виджетов.

Проверено на bp-demo Контрагенты:
- root (6 групп помещаются): {above:false, below:false}
- Покупатели at top: {above:false, below:true}
- after End: {above:true, below:false}
- after Home: {above:false, below:true}

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:02:33 +03:00
Nick Shirokov 80323a77cc test(web-test): расширить 18-cell-click — reveal-loop, horizontal scroll, skip-checkbox
3 новых шага на расширенном стенде (LongDoc + 18-колоночная ТЧ):

1. reveal-loop — открыть LongDoc через filterList по Комментарий, ТЧ Товары
   виртуализирована (13 строк в окне из 30). Клик с scroll:true по строке
   с Количество=25,000 — должен пролистать PageDown'ом до окна 20..30.

2. horizontal scroll туда-обратно — клик в Признак контроля (последняя,
   18-я колонка, ArrowRight scroll), потом сразу в Количество (2-я колонка,
   ArrowLeft scroll). Проверяет оба направления.

3. focus-click skip checkbox — кластер ВРезерве/НаКомиссии/Подарок у правого
   края дефолтного viewport. Клик в Серия (за пределами viewport) должен
   вызвать ArrowRight scroll с focus-pick на rightmost non-checkbox cell.
   Проверка: boolean'ы в строке 0 не изменились после клика.

Удалил устаревший Note про "перенесём на будущее" — теперь покрыто.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:12:25 +03:00
Nick Shirokov 0e5ad754e8 fix(web-test): focus-click для reveal/scroll не должен входить в edit-mode
После focus-click перед PageDown (reveal-loop) или ArrowRight/Left (horizontal
scroll) клавиатурная навигация ломалась если click попадал в Number/Date
ячейку — она автоматически входила в edit-mode, и стрелки начинали навигацию
внутри input вместо движения по гриду.

Два изменения:

1. findFocusCellScript generic mode (без direction) теперь берёт cells[0]
   вместо cells.slice(1) — то есть первую видимую колонку. В document
   tabular sections это типично Reference (Номенклатура), которая не
   входит в edit-mode по single click. Защиту от tree-toggles оставил
   точечно: для tree-гридов (presence of .gridBoxTree) пропускаем
   первую колонку как и раньше.

2. В click-cell.mjs после focus-click в revealAndFindCell и scrollGridToCell
   добавил тот же isInputFocusedInGrid + Escape страховочный фолбек,
   что и в deleteTableRow — на случай если focus всё же попал в input.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:12:08 +03:00
Nick Shirokov 9766b8262e test(web-test): кластер boolean ставим сразу после Источник для edge-теста
Чтобы при дефолтном открытии формы 3 boolean (ВРезерве, НаКомиссии, Подарок)
оказывались у правого края viewport. Это даёт прицельный сценарий для теста
focus-click при horizontal scroll — несколько checkbox подряд на краю
заставляют focus-pick walk их и взять non-checkbox дальше внутрь.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:05:06 +03:00
Nick Shirokov 44521c5c16 test(web-test): расширить синтетический стенд под cell-click сценарии
Готовит почву под полноценные тесты clickElement({row,column}) на гридах
формы и virtualization-сценарии:

ТЧ Товары документа ПриходнаяНакладная — было 6 колонок, стало 18:
- Единица, Скидка, СтавкаНДС, СуммаСНДС — для ширины и mix типов
- кластер 3 boolean (ВРезерве, НаКомиссии, Подарок) подряд — для теста
  что focus-click при horizontal scroll умеет пропускать checkbox edge
- Серия, НомерГТД, СтранаПроисхождения, СрокГодности — text/date
- ПризнакКонтроля (boolean) последней колонкой — отдельный edge-case

Новый enum СтавкиНДС (БезНДС, НДС0, НДС10, НДС20) — реалистичный
тип для СтавкаНДС, чтобы не переиспользовать КатегорииЦен (не по смыслу).

Документ LongDoc с 30 строками в ТЧ — для тестов reveal-loop через
scroll: true, когда строка вне DOM-окна виртуализированного грида.
Идентифицируется через Комментарий='LongDoc'.

Колонка Комментарий в форме списка ПриходнаяНакладная — чтобы можно
было искать LongDoc через filterList.

Существующее покрытие 05-table сохранено: добавление колонок
аддитивно, ранее проверенные ассерты не затронуты.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:42:31 +03:00
Nick Shirokov 8f2fa21814 fix(web-test): deleteTableRow выходит из cell edit-mode перед Delete
Delete-клавиша в режиме редактирования ячейки очищает буфер ввода,
а не удаляет строку. Это становилось проблемой когда:
1. предыдущий fillTableRow закончил Tab-навигацией в input (например
   в Number-ячейку соседней колонки), и фокус остался там;
2. сам click на Number/Date ячейку в deleteTableRow автоматически
   входит в edit-mode (поведение 1С).

Фикс: в deleteTableRow проверяем isInputFocusedInGrid дважды — до и
после click — и шлём Escape если активен INPUT в целевом гриде. Строка
остаётся выделенной после Escape, Delete срабатывает.

Дополнительно: isInputFocusedInGridScript / isInputFocusedInGrid теперь
принимают опциональный gridSelector — чтобы можно было прицельно проверять
конкретный грид на многогрид-формах (а не любой `.grid` на странице).

Покрытие: новый шаг в 05-table проверяет сценарий «фокус снаружи грида
(Комментарий), потом delete» — гарантирует что post-click Escape ловит
автоматический вход в edit-mode при клике на Number-ячейку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:42:10 +03:00
Nick Shirokov e05c0a4a61 feat(web-test): clickElement({row,column}) для гридов формы + readTable.hasMore
clickElement({row,column}) теперь работает не только на SpreadsheetDocument,
но и на гридах формы (динамические списки, табчасти). Маршрутизация:
spreadsheet приоритет (backward-compat), без spreadsheet — первый видимый
грид; явный table='Имя' форсит конкретный грид.

Поддержка:
- row: number — индекс в текущем DOM окне (виртуализация — документировано)
- row: { Колонка: значение } — фильтр по нормализованному содержимому
- scroll: true | number — reveal-loop через PageDown пока строка не найдена
  или DOM не перестал меняться (с лимитом)
- Автоматический горизонтальный скролл к колонке за viewport
  (учитывает frozen-колонки .gridBoxFix)
- Post-scroll visibility check — throw вместо ложного success

readTable обогащён полем hasMore: { above?, below } — единственный
надёжный сигнал виртуализации. total/shown остаются как DOM-окно
(backward-compat) с честным описанием в SKILL.md.

Общий хелпер scrollHorizontallyByKey вынесен в engine/core/, переиспользуется
spreadsheet'ом и грид-click'ом. DOM-логика (findGridCellScript,
findFocusCellScript, snapshotGridScript, resolveCellTargetScript) живёт
в dom/grid.mjs — engine только оркестрирует.

Покрытие: новый 18-cell-click.test.mjs (7 шагов: spreadsheet
regression-guard, catalog dblclick, табчасть, hasMore, 2 error-paths,
cleanup). Расширен 05-table.test.mjs проверкой hasMore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:01:11 +03:00
Nick Shirokov dff3ced847 merge: web-test engine refactor (Phase 1-5 + multi-select tests + README) 2026-05-28 13:28:05 +03:00
Nick Shirokov 403da66dd5 docs(web-test): README с CLI флагами, опциями стенда, известными нюансами
tests/web-test/README.md — практический mini-doc по запуску регресса:
- Запуск (полный / один файл / по тегам / по grep)
- CLI флаги runner'а (--tags, --grep, --bail, --retry, --timeout, --report,
  --format, --screenshot, --report-dir, --record)
- Опции стенда после `--` (--rebuild-config, --reload-data, --rebuild-epf,
  --rebuild-stand)
- Когда пересобирать стенд (warm-старт vs триггеры авто-пересборки vs
  ручные сценарии)
- Конфигурация (webtest.config.mjs с contexts a/b, isolation модели)
- Env переменные (WEB_TEST_PRESERVE_CLIPBOARD, WEBTEST_HOOKS_RUNTIME)
- Артефакты (error-*.png, _allure/, lockfiles)
- Известные нюансы:
  * 15-multi-context-handover накапливает Контрагентов между прогонами —
    `02-crud` ловит «`ООО Север` должен быть в списке» когда total>20.
    Лечится `-- --rebuild-stand`.
  * 04-selectvalue auto-history шаг делает warm-up для детерминизма.
  * --screenshot=every-step для full-trace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:21:35 +03:00
Nick Shirokov 70be567b13 test(web-test): покрытие multi-select (Ctrl/Shift + clickElement)
clickElement поддерживает modifier: 'ctrl'|'shift' для multi-select строк
списка с момента введения, но не было ни одного теста. Добавлен
17-multiselect.test.mjs:
- ctrl-add: click+ctrl-click → 2 выделенные строки
- shift-range: shift-click формирует диапазон от anchor'а
- readTable отмечает _selected: true на выделенных строках

Полный регресс 20/20 зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:25:26 +03:00
Nick Shirokov 60151c801f refactor(web-test): распил clickElement по доменам (Phase 5, §10)
core/click.mjs (307 LOC) распилен на тонкий dispatcher (~105 LOC) +
3 доменных handler-файла. Закрывает §10 родительского плана (отклонение
на этапе C.10 — «split — наследие плана»).

Структура:
- core/click.mjs (~105 LOC) — dispatcher: ensureConnected, spreadsheet-cell
  spec, highlight, confirmation/popup interception, findTarget, dispatch
  по target.kind
- core/helpers.mjs +modifierClick(x, y, modifier, {dbl?}) — общий
  mouse-click helper с поддержкой Ctrl/Shift модификаторов
- forms/click-popup.mjs (~90 LOC) — clickConfirmationButton +
  tryClickPopupItem (popup/confirmation внутри формы — форменный контекст,
  не навигация)
- forms/click-form.mjs (~107 LOC) — clickFormTarget: button/tab/submenu +
  netMonitor lifecycle + post-click submenu detection + confirmation hint
  propagation
- table/click-row.mjs (~95 LOC) — clickGridGroupTarget,
  clickGridTreeNodeTarget, clickGridRowTarget с переиспользованием
  modifierClick и существующих getGridToggleIcon/shouldClickToggle

Контракт dispatcher → handler: (target, ctx) где
ctx = {formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector}.
Handler возвращает returnFormState({clicked, ...}).

Граф зависимостей остаётся деревом:
- core/click.mjs → table/click-row, forms/click-popup, forms/click-form, spreadsheet
- table/{filter,grid,row-fill}.mjs → core/click.mjs (другие action-функции)
- handler-модули → helpers, wait, grid-toggle (НЕ click.mjs)

Поведение clickElement 1:1, публичный API без изменений.
netMonitor переехал внутрь clickFormTarget со своим try/finally.
Confirmation hint propagation (тот сайт что Phase 2 НЕ конвертировал)
переехал в clickFormTarget — естественное место.

Точечный регресс 7/7 (02-crud, 05-table, 08-hierarchy, 13-misc, 16-tree-form,
11-report, 01-navigation) + полный 19/19 зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:00:48 +03:00
Nick Shirokov a9949ff5fe refactor(web-test): uniform ok:true/false в filled-items + контракт fillTableRow (Phase 4)
Раньше per-item shape в filled[] был heterogeneous:
- success: {field, ok: true, method, value}
- failure: {field, error, message} ← без ok!

Естественная проверка `item.ok === false` молча промахивалась (latent bug в
production-клиенте Titan C:\WS\projects\titan\tests\helpers\query.mjs:69).
И документация утверждала «все функции throw'ают на ошибке» (guide.md:352),
что для fillTableRow было неправдой.

Что изменено:
- engine/table/row-fill.mjs v1.18 → v1.19: 15 error-pushes теперь включают
  ok: false. item.ok — единый дискриминатор success/failure.
- SKILL.md: fillFields раздел уточнён («throws on per-field failure — если
  вернулся, всё заполнено»). fillTableRow раздел: документирует контракт
  (НЕ throws на per-field), перечисляет error-коды и recovery hints
  (composite_type → retry с {value, type}, column_not_found → проверить
  readTable, и т.д.).
- docs/web-test-guide.md: строка 352 нюансирована (fillTableRow исключение
  из «все throw'ают»); строка 296 (таблица) уточнена.

Контракт обеих функций теперь сознательно различается и явно описан:
- fillFields = fail-fast (throws на любую ошибку, удобно для fill-and-go)
- fillTableRow = partial-recovery (errors в filled[] как ok:false, модель
  может retry'нуть селективно отдельную ячейку)

Бонус: query.mjs в Titan'е теперь работает корректно без правки клиентского
кода — cell.ok === false наконец-то дискриминирует error-items.

Полный регресс 19/19 зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:09:59 +03:00
Nick Shirokov 07353c416e refactor(web-test): унификация shape fillFields + fillTableRow (Phase 3)
Все action-функции теперь возвращают плоский form state с extras —
закрыта последняя аномалия API. Раньше:
- fillFields → {filled, form} (вложенный, документировано в SKILL.md)
- fillTableRow → 3 разных shape в 5 ветках (array | {filled, form} | {filled, notFilled, form}),
  при этом документация заявляла плоский — код её игнорировал

Теперь обе функции используют returnFormState({filled, notFilled?}) — тот же
паттерн что у всех action-функций после Phase 1+2 (clickElement, selectValue,
closeForm, filterList и т.д.).

Что закрывает:
1. Тихий баг в production-клиенте C:\WS\projects\titan\tests\helpers\query.mjs
   на res.filled?.find() — array-ветки fillTableRow возвращали [{...}] без .filled
   → ошибки заполнения параметров запросов молча пропускались. R1/R2-аналог.
2. Костыли r.filled || r в tests/web-test/05-table.test.mjs (2 места) —
   убраны, поскольку polymorphism устранён.
3. Расхождение код ↔ документация в fillTableRow.
4. Внутренний polymorphism в row-fill.mjs: убраны два `if (Array.isArray(more))`
   костыля в рекурсивных вызовах самого fillTableRow.

Файлы:
- engine/forms/fill.mjs v1.17 → v1.18 (1 ветка → returnFormState)
- engine/table/row-fill.mjs v1.17 → v1.18 (5 веток + 2 рекурсии)
- tests/web-test/05-table.test.mjs (r.filled || r → r.filled)
- .claude/skills/web-test/SKILL.md (сигнатуры fillFields/fillTableRow + общая
  ремарка про плоский return shape в начале раздела Actions)
- docs/web-test-guide.md (строки fillFields/fillTableRow/navigateSection;
  общая ремарка в начале раздела «Действия»)

В тестах ни один кейс не обращался к .form.X, blast radius нулевой.
Точечный регресс (03/05/06/07/10/16) и полный регресс 19/19 — зелёные.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:27:46 +03:00
Nick Shirokov 6e09351730 refactor(web-test): returnFormState idem дедуп (Phase 2, 5 сайтов)
Дедуп 5 идемпотентных хвостов action-функций — паттерн
getFormState + state.X = ... + checkForErrors + if(err) state.errors = err + return
сводится к одному returnFormState(extras). Поведение identical.

- core/click.mjs: ветка popupItem (1 сайт)
- forms/select-value.mjs: openFormAndPick helper, DLB dropdown match,
  DLB dropdown no-search-first, selection-form direct 3B (4 сайта)

Аудит upload/returnFormState-audit.md обещал 13 idem, но при разборе:
- click.mjs:final (custom confirmation/hint), close.mjs save=undefined (R3 предупреждение),
  6 сайтов fillReferenceField (приватный shape {field, ok, method, value}, не form-state)
— осознанно НЕ конвертированы. Реальный Phase 2 — 5 сайтов.

Полный регресс 19/19 зелёный (после rebuild стенда — старый стенд накопил
22 Контрагента вместо 4 за прошлые прогоны, не связано с Phase 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:09:24 +03:00
Nick Shirokov 961f27afb0 refactor(web-test): deleteTableRow coords + reuse countGridRows (S10)
19 LOC inline cellCoords + 8 LOC inline row-count в engine/table/grid.mjs
deleteTableRow → новый findDeleteRowCoordsScript в dom/grid.mjs + переиспользован
существующий countGridRowsScript (dom/grid.mjs:268, добавлен в S5).

Engine-сторона deleteTableRow становится чистым оркестратором: resolve grid →
get coords → click → Delete → count rows.

DOM-extraction iter 2 / S10 из плана. Точечный регресс 05-table зелёный +
полный регресс 19/19 зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:33:21 +03:00
Nick Shirokov 8f0d3937b4 refactor(web-test): scanGridRows вынесен в dom/grid.mjs (S9)
25 LOC inline page.evaluate в forms/select-value.mjs:30 → новый экспорт
scanGridRowsScript(formNum, searchLower) в dom/grid.mjs (рядом с
readTableScript, getSelectedOrLastRowIndexScript). Engine-сторона —
тонкая обёртка одной строкой.

DOM-extraction iter 2 / S9 из плана. Точечный регресс 04-selectvalue зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:18:48 +03:00
Nick Shirokov f554ef4599 refactor(web-test): вынести error-stack scraping в dom/errors-stack.mjs (S8)
5 inline page.evaluate блоков (~50 LOC) из fetchStackViaReport (path-1 OpenReport
flow платформенных исключений) → новый dom/errors-stack.mjs с 5 экспортами:
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
clickReportCloseButtonScript.

Engine-сторона fetchStackViaReport теперь читается как чистый оркестратор
6 шагов без длинных DOM-строк. Поведение 1:1.

DOM-extraction iter 2 / S8 из плана. Точечный регресс 14-errors-stack зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:15:51 +03:00
Nick Shirokov 486890c388 test(web-test): сделать 04-selectvalue auto-history детерминированным
Step auto-history полагался на наполненную UserChoiceHistory от предыдущих тестов
(06-document и т.д.) — в одиночном прогоне history для 'ООО Юг' пустая,
typeahead не активировался, method=form вместо ожидаемого dropdown.

History в 1С — per-value: первый выбор значения через form наполняет историю,
второй выбор того же значения идёт через typeahead-dropdown. Добавлен warm-up:
selectValue('Менеджер', 'ООО Юг') → clear → второй selectValue того же значения
(уже из истории).

Закрывает §0.8 #4 родительского плана. Регресс одиночный + полный 19/19 зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:03:00 +03:00
Nick Shirokov 707033e25b refactor(web-test): returnFormState в nav + grid + spreadsheet + selectValue (7 веток)
navigation.mjs: navigateSection (sections+commands ветка), switchTab, openFile
(оба success-сайта). Закрывает R1/R2 для навигационных action-функций.

grid.mjs: deleteTableRow — заодно унификация shape с SKILL.md ("→ form state"
плоский). До этого код возвращал {deleted, ..., form: formData} в нарушение
документации; теперь плоский state с extras. JSDoc обновлён.

spreadsheet.mjs: clickSpreadsheetCell. select-value.mjs: ветка clear-success
selectValue (search=null, method='clear').

Phase 1 / C3 (final) из плана upload/returnFormState-audit.md. Полный регресс 19/19
зелёный. Известный pre-existing test-isolation issue в одиночном прогоне
04-selectvalue (auto-history) — описан в backlog §0.8 #4 родительского плана.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:41:13 +03:00
Nick Shirokov a381fca0a1 refactor(web-test): returnFormState в close.mjs + filter.mjs (7 веток)
closeForm: platform-dialogs, save=true/false, final-escape — теперь подмешивают
state.errors через returnFormState. Ветка save=undefined (hint-return)
осознанно оставлена без errors (юзер ещё не принял решение).

filterList: simple search, advanced search — закрывают R1/R2.
unfilterList: selective (field) + clear-all — аналогично.

Phase 1 / C2 из плана upload/returnFormState-audit.md. Точечный регресс зелёный
(02-crud, 06-document, 09-filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:21:00 +03:00
Nick Shirokov 280df54fa6 refactor(web-test): returnFormState в click.mjs (10 веток)
Фикс тихих багов R1/R2 — каждая ветка clickElement теперь подмешивает state.errors
через хелпер returnFormState (engine/core/helpers.mjs). До правки ветки confirmation,
submenuArrow, gridGroup/gridTreeNode (toggle+default), gridRow (click/dblclick),
submenu (pre+post-wait) возвращали state без checkForErrors → exec-wrapper не throw'ал
на soft validation errors (balloon/modal).

Phase 1 / C1 из плана upload/returnFormState-audit.md. Точечный регресс зелёный
(02-crud, 05-table, 08-hierarchy, 13-misc, 16-tree-form).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:15:02 +03:00
Nick Shirokov 8fd5544abd refactor(web-test): bump browser.mjs до v1.18 (финализация S1–S6)
Завершён рефакторинг §0.5 п.5 родительского плана (вынос inline DOM в
dom/). Итоговые метрики:

| Файл              | До (LOC, evals) | После (LOC, evals) |
|-------------------|-----------------|--------------------|
| row-fill.mjs      | 1235 / 47       |  793 / 1           |
| select-value.mjs  |  959 / 25       |  827 / 5           |
| filter.mjs        |  390 / 17       |  256 / 0           |
| Σ engine hot      | 2584 / 89       | 1876 / 6           |

Снижение LOC −708 (−27%), inline page.evaluate −83 (−93%).

dom/ расширился с 7 до 11 файлов: новые edd.mjs, edit-state.mjs,
filter.mjs, grid-edit.mjs; расширены forms.mjs (+16 функций) и
grid.mjs (+4 функции).

Engine-модули стали orchestrator-ами. Публичный API browser.mjs —
56 экспортов, без изменений. Полный регресс зелёный после каждого
этапа S1–S6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:32:09 +03:00
Nick Shirokov b518b614bb refactor(web-test): извлечён grid-edit домен в dom/grid-edit.mjs
Тяжёлые DOM-блоки row-fill (15–60 строк) вынесены в 8 именованных
функций dom/grid-edit.mjs. row-fill стал плоским orchestrator-ом.

Новое в dom/grid-edit.mjs:
- sortFieldKeysByColindexScript    — сортировка keys по colindex (Tab-нав)
- findCellCoordsByFieldsScript     — клетка по first matching header (multi)
- findNextCellCoordsByKeyScript    — клетка по single key + no-space fuzzy
- findCheckboxAtPointScript        — checkbox info по координатам elementFromPoint
- findRowCommitClickCoordsScript   — клик OTHER row для commit-edit
- getGridEditCheckScript           — { inEdit, tag?, hint? } диагностика
- readActiveGridCellScript         — активная клетка (id, fullName, headerText)
- getElementCenterCoordsByIdScript — центр по id (дедуп 2 копий)

Метрики row-fill: 971 → 793 LOC (−178, S6 alone), inline page.evaluate
10 → 1 (значительно ниже плановой цели ≤10). Все табличные suite
зелёные (05/06/08/10/16), полный регресс зелёный (Checkpoint-2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:31:02 +03:00
Nick Shirokov b08ee99521 refactor(web-test): извлечены grid read-helpers + cloud-popup в dom/
Новое в dom/grid.mjs (все принимают опциональный gridSelector):
- countGridRowsScript            — кол-во .gridLine в body
- isTreeGridScript               — тип grid'а (есть .gridBoxTree)
- findGridHeadCenterCoordsScript — центр .gridHead для commit-клика
- getSelectedOrLastRowIndexScript — selected row index, fallback на последний

Также:
- isInputFocusedInGrid wrapper (S1) применён в add-row "ready" поллинге
- isNotInListCloudVisibleScript (S3) применён вместо локального notInList
- clickShowAllInNotInListCloudScript — новая в dom/forms.mjs (клик
  "Показать все" в "нет в списке" cloud popup через dispatchEvent)

Метрики row-fill: 1041 → 971 LOC (−70), evaluates 17 → 10. Регресс
05/08/16/10 — зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:07:10 +03:00
Nick Shirokov 89efcad125 refactor(web-test): извлечён EDD-домен в dom/edd.mjs
Новое в dom/edd.mjs:
- readEddScript                  — {visible, items:[{name,x,y}]}
- isEddVisibleScript             — boolean, лёгкая проверка
- clickEddItemViaDispatchScript  — клик по name через dispatchEvent (bypass
                                   div.surface); fuzzy с NBSP/ё/bracket-strip
- clickShowAllInEddScript        — "Показать все" в footer

Wrappers в helpers.mjs: readEdd, isEddVisible, clickEddItemViaDispatch,
clickShowAllInEdd. row-fill clickEddItem унифицирован с select-value
вариантом (NBSP/ё normalization теперь работает и для табличных строк).

Метрики: select-value 880 → 827 LOC (−53), row-fill 1065 → 1041 LOC
(−24); evaluates row-fill 20 → 17, select-value 7 → 5. Полный регресс
зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:57:58 +03:00
Nick Shirokov 340142b0a2 refactor(web-test): извлечены DOM-скрипты dialog/picker UI из select-value
Применены shared-функции из S2 (findSearchInputScript, findNamedButton,
findCompareTypeRadio, isFormVisible). Добавлены новые для type-dialog и
picker UI:
- findPatternInputIdScript          — Pattern input id (Alt+F dialog)
- isTypeDialogScript                — OK + ValueList + "Выбор типа" title
- isNotInListCloudVisibleScript     — "нет в списке" tooltip popup
- findChildFormByButtonScript       — поиск child-form по имени кнопки
- readTypeDialogVisibleRowsScript   — visible rows + fuzzy matches в ValueList

select-value.mjs: 950 → 880 LOC (−70), inline page.evaluate 24 → 7
(планировали ≤8). Регресс 06/11/13 зелёный; полный регресс зелёный
(Checkpoint-1 пройден). 04-selectvalue auto-history шаг — pre-existing
test-isolation issue (см. S1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:38:01 +03:00
Nick Shirokov 7f7ab2f217 refactor(web-test): извлечены DOM-скрипты filter.mjs в dom/filter.mjs
Все 17 inline page.evaluate в engine/table/filter.mjs вынесены в
именованные dom-генераторы. Engine-модуль стал чистым orchestrator-ом.

Новое в dom/forms.mjs (shared с будущим S3 select-value):
- findSearchInputScript(formNum)        — поиск SearchString/ПоискаСтроки input
- findNamedButtonScript(text)           — кнопка a.press по innerText (Найти, OK)
- findCompareTypeRadioScript(form, idx) — радио CompareType#N#radio
- isFormVisibleScript(form)             — есть ли видимые элементы form{N}

Новое в dom/filter.mjs:
- findFirstGridCellCoordsScript          — координаты первой клетки грида
- findColumnFirstCellCoordsScript        — клетка по имени колонки (fuzzy header
                                           match с needDlb-fallback)
- readFieldSelectorInfoScript            — FieldSelector value + DLB coords
- pickFieldInSelectorDropdownScript      — выбор поля в FieldSelector DLB-edd
- readFilterDialogInfoScript             — Pattern id+value+isDate+isRef
- findFilterBadgeCloseScript             — × badge по имени поля
- findFirstFilterBadgeCloseScript        — × первого видимого badge (для clear-all)

Попутно: добавлен импорт readSubmenuScript (был pre-existing broken
import в Еще-fallback ветке Alt+F).

Метрики filter.mjs: 390 → 256 LOC (−134, −34%), inline page.evaluate
17 → 0. Регресс 09-filter / 02-crud / 05-table — зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:16:23 +03:00
Nick Shirokov 85003782db refactor(web-test): извлечены detect-new-form и edit-state из inline в dom/
Дедуплицированы 15 копий detect-new-form (13 в row-fill + 2 локальные
обёртки в select-value), 6 копий INPUT-focused, 4 проверки calendar/
calculator popup, 1 INPUT-focused-inside-grid.

Новое:
- dom/forms.mjs: detectNewFormScript(prev, {strict}) — объединяет broad
  и strict варианты
- dom/edit-state.mjs: isInputFocusedScript({allowTextarea}),
  isInputFocusedInGridScript, findOpenPopupScript
- helpers.mjs: переписан detectNewForm на dom-script; добавлены тонкие
  обёртки isInputFocused, isInputFocusedInGrid, findOpenPopup

Метрики row-fill: 1235 → 1065 LOC (−170), inline page.evaluate 47 → 20.
Поведение идентично; точечный регресс зелёный (02/03/05/06/10/16).
04-selectvalue auto-history шаг — pre-existing baseline issue (state-
driven, не связан с S1, воспроизводится на HEAD).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:54:36 +03:00
Nick Shirokov 65ea06ab6e refactor(web-test): run.mjs распилен по cli/ (1258 → 65 LOC entry)
Внутренности move в cli/:
- util.mjs — out/die/json/readBody/readStdin/elapsed/elapsed2/slugify/formatDuration/xmlEscape/interpolate/printSteps/usage
- session.mjs — SESSION_FILE, loadSession, cleanup
- exec-context.mjs — buildContext, buildScopedContext, executeScript
- server.mjs — handleRequest (HTTP сервер в процессе start)
- commands/{start,run,exec,shot,stop,status,test}.mjs — по одной команде на файл
- test-runner/assertions.mjs — createAssertions (ctx.assert API)
- test-runner/severity.mjs — SEVERITY_RANK/LEVELS, buildSeverityIndex, resolveSeverity
- test-runner/reporters.mjs — writeAllure, allureStep, syncAllureExtras, buildJUnit
- test-runner/discover.mjs — discoverTests, resetState

run.mjs остался публичным entry-point с CLI-парсингом и dispatcher'ом.
Регресс tests/web-test/ зелёный (19/19, 9m 28s).
2026-05-26 18:08:15 +03:00
Nick Shirokov 71607bef99 refactor(web-test): dom.mjs распилен по dom/ (1434 → 41 LOC facade)
Внутренности movе в dom/:
- _shared.mjs — HAS_VISIBLE_MODAL_FN, DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN
- forms.mjs — detectFormScript, readFormScript, findClickTargetScript, findFieldButtonScript, resolveFieldsScript
- form-state.mjs — getFormStateScript
- grid.mjs — resolveGridScript, readTableScript
- nav.mjs — readSectionsScript, readTabsScript, switchTabScript, readCommandsScript, navigateSectionScript, openCommandScript
- submenu.mjs — readSubmenuScript, clickPopupItemScript
- errors.mjs — checkErrorsScript

dom.mjs остался публичным entry-point с теми же 17 экспортами.
Регресс tests/web-test/ зелёный (19/19, 9m 22s).
2026-05-26 17:47:13 +03:00
Nick Shirokov c930b4b04d refactor(web-test): spreadsheet выделен в собственную папку
SpreadsheetDocument (отчёты, печатные формы) — другой домен, чем form-grid
(табличные части документов, списки). Раньше лежал внутри table/, что
было обманчиво.

  engine/table/spreadsheet.mjs → engine/spreadsheet/spreadsheet.mjs

Структура engine/:
  core/         плумбинг движка (state, wait, errors, session, click, ...)
  forms/        работа с формами (fill, close, select-value, state)
  nav/          навигация
  table/        form-grid (grid, row-fill, filter, grid-toggle)
  spreadsheet/  SpreadsheetDocument
  recording/    запись + overlays

В будущем при росте spreadsheet можно распилить — engine/spreadsheet/cells.mjs,
engine/spreadsheet/scroll.mjs и т.д. без переименований.

11-report регресс зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:52:15 +03:00
Nick Shirokov 8bdcb9e664 refactor(web-test): form-state переехал из core/ в forms/
getFormState — высокоуровневая операция «прочитать состояние формы»,
семантически в forms/ ближе чем в core/ (foundational плумбинг движка).

  engine/core/form-state.mjs → engine/forms/state.mjs

Все 11 importer'ов обновлены. Внутри state.mjs пути исправлены:
'./state.mjs' → '../core/state.mjs', './errors.mjs' → '../core/errors.mjs'.

03-fillfields регресс зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:48:08 +03:00
Nick Shirokov ab10761667 chore(web-test): почистить устаревшие комментарии и неиспользуемые импорты
После полной чистки cycle-импортов в E.13 остались комментарии типа
"getFormState still in browser.mjs", которые больше не верны (он переехал
в engine/core/form-state.mjs). Сметаем устаревшие "moved to / lives in
browser.mjs" комментарии в 8 файлах.

Дополнительно в engine/table/spreadsheet.mjs:
  - убраны неиспользуемые импорты readTableScript, resolveGridScript, normYo
    (остались с тех пор, как readTable жил в этом файле — до этапа D.12
    rename'а в grid.mjs)
  - заголовочный комментарий обновлён (без упоминания readTable)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:42:17 +03:00
Nick Shirokov a24c39b6de refactor(web-test): этап E.13 — финализация (v1.17 + чистый facade + чистка)
1. Версия v1.16 → v1.17 во всех заголовках движка.

2. browser.mjs стал чистым facade — только re-exports, 0 функций определено.
   Было: 249 LOC с 4 настоящими функциями (saveClipboard, restoreClipboard,
   pasteText, getFormState) — теперь 57 LOC чистых re-export'ов.

3. engine/core/clipboard.mjs — новый модуль:
     pasteText + saveClipboard + restoreClipboard (~85 LOC, был в browser.mjs).

4. engine/core/form-state.mjs — новый модуль:
     getFormState — центральный читатель состояния формы (~30 LOC).

5. Убрано 12 циклических импортов из engine/* → ../../browser.mjs:
   - Все читатели pasteText теперь импортят из engine/core/clipboard.mjs
   - Все читатели getFormState — из engine/core/form-state.mjs
   - session.mjs → nav/navigation.mjs (getPageState напрямую)
   - filter.mjs → core/click.mjs (clickElement напрямую)
   Граф зависимостей стал деревом (без обратных рёбер).

6. Убраны _-префиксы у 9 функций, которые стали приватными внутри своих
   модулей (раньше _ означало "приватная для browser.mjs"):
     _detectPlatformDialogs → detectPlatformDialogs
     _closePlatformDialogs → closePlatformDialogs
     _parseErrorStack → parseErrorStack
     _fetchStackViaReport → fetchStackViaReport
     _fetchStackViaHamburger → fetchStackViaHamburger
     _logoutSlot → logoutSlot
     _saveActiveSlot → saveActiveSlot
     _activateSlot → activateSlot
     _attachSessionListeners → attachSessionListeners

Публичный API: 56 экспортов, идентичный исходному.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:25:15 +03:00
Nick Shirokov 8739d1d15c refactor(web-test): структура — engine/ wrapper для внутренних модулей
Перенос всей внутрянки движка под scripts/engine/:
  - core/, forms/, nav/, table/, recording/ → engine/<same>/

Публичные entry-точки остаются в scripts/ корне без изменений:
  - browser.mjs, dom.mjs, run.mjs — компат не ломаем.

Симметричный layout, легко читать с первого взгляда:
  scripts/
    browser.mjs, dom.mjs, run.mjs    ← публичные entries
    engine/                          ← внутренности движка
    (dom/, cli/ — место под будущий распил dom.mjs / run.mjs)

Технические правки после переезда:
  - browser.mjs: ./core/... → ./engine/core/... (23 импорта)
  - engine/*/* модули: ../browser.mjs → ../../browser.mjs (11 импортов)
  - engine/*/* модули: ../dom.mjs → ../../dom.mjs (12 импортов)
  - engine/recording/capture.mjs: dynamic import('../browser.mjs')
    → import('../../browser.mjs')
  - engine/core/state.mjs: projectRoot пересчитан (5 → 6 уровней вверх)
  - Git rename detection срабатывает — история файлов сохраняется

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:03:20 +03:00
Nick Shirokov f31770d79c refactor(web-test): этап D.12 — fillTableRow → row-fill.mjs, deleteTableRow → grid.mjs
table/row-fill.mjs (~1230 LOC): fillTableRow целиком, как entry-point.
table/grid.mjs: добавлен deleteTableRow + расширены импорты (clickElement,
dismissPendingErrors, waitForStable, getFormState).

Дальнейший распил fillTableRow на под-хелперы (trySelect/readVisibleRows/
pickValueWithOptionalType/enterEditMode/fillCellSequentially per плана §12.1-12.3)
отложен — при необходимости создаём table/row-fill/*.mjs subfolder.

browser.mjs: 1532 → 249 LOC (-84% от baseline 6293).
05-table регресс зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:02:47 +03:00
Nick Shirokov a5c0be6766 refactor(web-test): переименование — readTable в table/grid.mjs
readTable читает form-grid (.gridLine/.gridBody — табличные части на форме,
списки), а не SpreadsheetDocument. Имя файла table/spreadsheet.mjs было
обманчиво. Разделяем домены:

  table/grid.mjs        ← readTable (form-grid операции, готово
                         для fillTableRow + deleteTableRow в D.12)
  table/spreadsheet.mjs ← readSpreadsheet + cell helpers (только
                         SpreadsheetDocument — отчёты, печатные формы)

Поведение 1-в-1. browser.mjs re-export обновлён.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:59:28 +03:00
Nick Shirokov 50d40a9dd5 fix(web-test): добавить getFormState в импорты table/spreadsheet.mjs
clickSpreadsheetCell вызывает getFormState в конце (для drill-down формы),
но import не был добавлен при экстракции на C.11. ReferenceError в
11-report drill-down. Импортируем из browser.mjs (циклически).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:26:48 +03:00
Nick Shirokov 0ba8127d52 refactor(web-test): этап C.11 — table/spreadsheet.mjs + table/filter.mjs
table/spreadsheet.mjs (~580 LOC):
  - readTable, readSpreadsheet
  - scanSpreadsheetCells, buildSpreadsheetMapping (private)
  - scrollSpreadsheetToCell, clickSpreadsheetCell, findSpreadsheetCellByText

table/filter.mjs (~390 LOC): filterList, unfilterList

После C.11 + C.10 clean-up:
  - core/click.mjs импортит clickSpreadsheetCell/findSpreadsheetCellByText
    напрямую из table/spreadsheet.mjs (а не из browser.mjs)
  - browser.mjs больше не реэкспортирует эти два — публичный API
    остаётся 56 экспортов как до рефакторинга
  - Добавил import { clickElement } в browser.mjs для внутренних вызовов
    из fillTableRow/deleteTableRow

browser.mjs: 2470 → 1532 LOC (≈75% от исходных 6293).
05-table + 09-filter регресс зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:14:49 +03:00
Nick Shirokov 9ee0473412 refactor(web-test): этап C.10 — clickElement → core/click.mjs (целиком)
clickElement (~300 LOC) перенесён единым блоком в core/click.mjs.
Поведение 1-в-1. Внутри остаётся всё ветвление (spreadsheet, submenu,
gridGroup/Parent, gridTreeNode, gridRow, tab, button) — разнос на
forms/click-form.mjs + nav/click-popup.mjs + finer table-toggle
отложен на E.13 для безопасности.

clickSpreadsheetCell + findSpreadsheetCellByText временно exported из
browser.mjs (нужны core/click.mjs). На C.11 они переедут в
table/spreadsheet.mjs, экспорт из browser.mjs можно будет убрать.

browser.mjs: 2768 → 2470 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:07:22 +03:00
Nick Shirokov cbd580a0bd refactor(web-test): этап C.9 — выделить forms/fill.mjs + forms/close.mjs
forms/fill.mjs (~140 LOC): fillFields, fillField
forms/close.mjs (~50 LOC): closeForm
clickElement остаётся в browser.mjs до C.10.

Допиленные импорты после первого прохода:
  - fill.mjs: readFormScript, normYo (из dom/state — забыл при экстракции)
  - close.mjs: recorder (используется для паузы 500ms при confirmation
    во время записи)

03-fillfields регресс зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:04:09 +03:00
Nick Shirokov d67874ebd0 Merge branch 'dev' into refactor/web-test-engine 2026-05-26 12:39:22 +03:00
Nick Shirokov 6781bb3ee5 fix(skd-compile): авто-выборка и авто-порядок в группах из shorthand-структуры
Платформа добавляет SelectedItemAuto и OrderItemAuto в каждую группировку при
ручном создании в конфигураторе. Shorthand-запись (например
'Номенклатура > details') теперь даёт эквивалентный результат — каждая
группа получает selection=['Auto'] и order=['Auto']. Без этого roundtrip
decompile→compile терял авто-элементы.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:38:13 +03:00
Nick Shirokov 3a6d5abffc refactor(web-test): этап C.8 — выделить forms/select-value.mjs
Перенос selectValue + helpers из browser.mjs (~960 LOC):
  - scanGridRows, dblclickAndVerify, advancedSearchInline
  - pickFromSelectionForm, isTypeDialog, pickFromTypeDialog (экспортируются —
    вызываются из fillFields/fillTableRow в browser.mjs)
  - fillReferenceField (экспортируется — вызывается из fillFields)
  - selectValue

Двумя слайсами вокруг fillFields/fillField/clickElement/closeForm, которые
остаются в browser.mjs до этапов C.9/C.10.

browser.mjs: 4095 → 2933 LOC. 56 публичных экспортов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:28:31 +03:00
Nick Shirokov c4b1aee9c9 refactor(web-test): этап C.7 — выделить nav/navigation.mjs
Перенос navigation-функций из browser.mjs (~240 LOC):
  - getPageState, getSections, navigateSection, getCommands
  - openCommand, switchTab
  - openFile (Ctrl+O + security dialog flow)
  - navigateLink (Shift+F11 e1cib paste)
  - E1CIB_TYPE_MAP, E1CIB_APP_TYPES, normalizeE1cibUrl (приватные)

Цикл с browser.mjs (getFormState, pasteText) — статический ESM-импорт,
разрешается во время вызова (binding live). core/session.mjs продолжает
импортить getPageState из browser.mjs через re-export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:16:56 +03:00
Nick Shirokov 12c5cf5e66 fix(web-test): TDZ в selectValue (detectNewForm) + missing import clipboardWarnLogged
1. В selectValue локальный const detectNewForm = () => ... объявлялся ниже
   composite-type ветки, которая его вызывала → TDZ ReferenceError "Cannot
   access 'detectNewForm' before initialization". Хелпер поднят в начало
   функции, дубликат-объявление убрано.

2. clipboardWarnLogged читается в restoreClipboard (line 92), но не был
   в списке импортов из core/state.mjs (импортировался только setter).
   ReferenceError срабатывал только когда clipboard.read() возвращал
   ошибку — в первом A-регрессе ветка не активировалась случайно.

Регресс 18/19 (одна flake в 11-report — readSpreadsheet timing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:58:26 +03:00
Nick Shirokov 6fb5b9f617 refactor(web-test): этап B.6 — table/grid-toggle.mjs (icon detection shared)
В clickElement две ветки (gridGroup/gridParent + gridTreeNode) имели
почти идентичные page.evaluate-блоки: найти gridLine под target.y,
получить иконку-разворачивалку, вернуть её центр + isExpanded.

table/grid-toggle.mjs:
  - getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr })
  - shouldClickToggle(iconInfo, expand, toggle)

Поведение 1-в-1. Селекторы и isExpanded-критерий передаются параметрами:
  - groups: '.gridListH, .gridListV' + icon.classList.contains('gridListV')
  - trees:  '.gridBoxImg [tree="true"]' + bg.includes('gx=0')

Экономия ~30 LOC дублей.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:44:18 +03:00
Nick Shirokov 9ac0cb3b87 refactor(web-test): этап B.5.5 — ввести returnFormState (выборочно применить)
core/helpers.mjs: returnFormState(extras) — стандартный хвост action-функций:
getFormState + Object.assign(extras) + checkForErrors → state.errors. Унифицирует
~15 hand-written копий и закрывает R1/R2/R3 (state.errors теперь добавляется
автоматически у любого пользователя хелпера).

В этом коммите конвертированы только 2 простейших P1-сайта (openCommand,
второй handle в navigateLink) — без extras между getFormState и err-проверкой.
Остальные 30+ сайтов сложнее (state.X между, разные return-shape, wrapped
fillFields) — будут мигрированы органически при переносе clickElement/
selectValue/closeForm в forms/* на этапе C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:42:23 +03:00
Nick Shirokov e215957344 refactor(web-test): этап B.5.4 — readEdd хелпер (2 копии в fillReferenceField)
В fillReferenceField было два места с одинаковым page.evaluate-скриптом
чтения #editDropDown (DLB-popup перед paste и autocomplete после Ctrl+V).

core/helpers.mjs: readEdd() → { visible, items?: [{ name, x, y }] }.

selectValue использует свой clickEddItem через dispatchEvent (bypass div.surface) —
оставлен как есть, специфика API там сильно отличается.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:35:24 +03:00
Nick Shirokov 09b2084672 refactor(web-test): этап B.5.3 — detectNewForm хелпер (3 копии → 1)
В fillReferenceField, selectValue и fillTableRow была одна и та же логика:
сканировать DOM на наличие элемента с id="form{N}_*" где N > prevFormNum.
Две вариации: strict (только visible interactive — input.editInput/a.press)
и broad (любой [id], учитывает type-dialogs с пустыми button-id).

core/helpers.mjs: detectNewForm(prevFormNum, { strict }) → number|null.
Внутри функций оставлены тонкие локальные обёртки (для совместимости
с уже использующейся сигнатурой без аргументов) — будут убраны на C.8/D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:34:21 +03:00
Nick Shirokov 3fe038277f refactor(web-test): этап B.5.2 — findFieldInputId хелпер (4 копии → 1)
В selectValue было 4 одинаковых блока поиска input-элемента поля
по имени (form{N}_{name} либо form{N}_{name}_i0 для refs):
clear-ветка, composite-type-ветка, F4-fallback, "last resort" F4.

core/helpers.mjs: findFieldInputId(formNum, fieldName) → string|null.
~30 LOC дублей убрано, поведение 1-в-1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:33:15 +03:00
Nick Shirokov 5b6243bbcc refactor(web-test): этап B.5.1 — safeClick хелпер вместо 3 копий pointer-events retry
В fillReferenceField, clickElement и DLB-ветке selectValue был один и тот же
паттерн: page.click → catch 'intercepts pointer events' → force-click →
catch снова → Escape + retry. Три копии (плюс одна с dismissPendingErrors).

core/helpers.mjs (новый): safeClick(selector, { timeout, dismissErrors }).
Экономия ~60 LOC дублей. Поведение 1-в-1 (dismissErrors:true только в
fillReferenceField — там единственное место, где исходно было).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:31:45 +03:00
Nick Shirokov 2cba13a8cc fix(web-test): экспортировать _detectPlatformDialogs/_closePlatformDialogs из core/errors.mjs
После A.3 эти helpers стали приватными в core/errors.mjs, но getFormState
(browser.mjs:408) и closeForm (browser.mjs:2168) их по-прежнему вызывают —
ловили ReferenceError на каждое действие. Делаем их экспортируемыми
и импортируем в browser.mjs. Имя с подчёркиванием сохраняется до этапа E.13
(финальная чистка). Регресс 19/19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:25:54 +03:00
Nick Shirokov fca65ef658 refactor(web-test): этап A.4 — выделить core/session.mjs
Перенос session-функций из browser.mjs (~380 LOC):
  - connect, disconnect, attach, detach, getSession
  - createContext, setActiveContext, listContexts, getActiveContext,
    hasContext, closeContext
  - findExtension (приватная)
  - _logoutSlot, _saveActiveSlot, _activateSlot, _attachSessionListeners
    (приватные multi-context хелперы)

Session-модуль зависит от core/state, core/errors (closeModals),
recording/capture (stopRecording) и циклически от browser.mjs
(getPageState — переедет в nav/navigation.mjs на этапе C.7).
ESM live-binding делает цикл безопасным: getPageState вызывается
только внутри async функций, а не на этапе загрузки модуля.

browser.mjs: 4251 LOC, 56 публичных экспортов. Завершает Чекпоинт A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:12:07 +03:00
Nick Shirokov 4f01f01286 refactor(web-test): этап A.3 — выделить core/wait.mjs + core/errors.mjs
core/wait.mjs (123 LOC):
  - waitForStable: smart DOM-stability polling
  - waitForCondition: JS-expression polling
  - startNetworkMonitor: CDP network-activity monitor

core/errors.mjs (336 LOC):
  - closeModals, dismissPendingErrors, checkForErrors, fetchErrorStack
  - Платформенные диалоги: _detectPlatformDialogs, _closePlatformDialogs
  - _parseErrorStack, _fetchStackViaReport, _fetchStackViaHamburger (приватные)

browser.mjs импортирует их для внутреннего использования и re-export'ит
только fetchErrorStack (исходно публичный). Остальные функции остаются
приватными — публичный API не меняется (56 экспортов).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:10:31 +03:00
Nick Shirokov 398c515390 refactor(web-test): этап A.2 — вынести recording/* в отдельные модули
Перенос ~1200 LOC из browser.mjs в recording/{tts,captions,capture,highlight,narration}.mjs:
  - tts.mjs: resolveFfmpeg, resolveEdgeTts, edge/openai/elevenlabs providers,
    getTtsProvider, getAudioDuration, generateSilence
  - captions.mjs: showCaption/hideCaption/getCaptions, showTitleSlide/
    hideTitleSlide, showImage/hideImage
  - capture.mjs: screenshot, wait, isRecording, startRecording, stopRecording
  - highlight.mjs: highlight, unhighlight, setHighlight, isHighlightMode
  - narration.mjs: addNarration

browser.mjs стал тоньше на 1200 строк, re-export через `export { ... } from './recording/*.mjs'`.
Публичный API сохранён (56 экспортов). state.mjs нормализован на CRLF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:07:32 +03:00
Nick Shirokov cecf4dd9a2 refactor(web-test): этап A.1 — выделить module-level state в core/state.mjs
Состояние движка (browser, page, sessionPrefix, seanceId, recorder, контексты,
константы, normYo, isConnected/ensureConnected/getPage) переехало в
core/state.mjs. Импортируется как live-binding; присваивания в browser.mjs
конвертированы в setX(...) — ESM imports read-only.

Публичный API не меняется (56 экспортов). Регресс 19/19 зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:00:53 +03:00
351 changed files with 42503 additions and 13700 deletions
+40 -14
View File
@@ -61,7 +61,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
| DSL ключ | XML элемент | Значение ключа |
|--------------|-------------------|---------------------------------------------------|
| `"group"` | UsualGroup | `"horizontal"` / `"vertical"` / `"alwaysHorizontal"` / `"alwaysVertical"` / `"collapsible"` |
| `"group"` | UsualGroup | ориентация: `"vertical"` / `"horizontalIfPossible"` / `"alwaysHorizontal"` (поведение — отдельный ключ `behavior`) |
| `"columnGroup"` | ColumnGroup | `"horizontal"` / `"vertical"` / `"inCell"` — только внутри `columns` таблицы |
| `"input"` | InputField | имя элемента |
| `"check"` | CheckBoxField | имя |
@@ -83,15 +83,15 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
| Ключ | Описание |
|------|----------|
| `name` | Переопределить имя (по умолчанию = значение ключа типа) |
| `name` | Переопределить имя (по умолчанию = значение ключа типа). Имена уникальны во всех коллекциях формы (элементы, реквизиты, команды, колонки) |
| `title` | Заголовок элемента |
| `tooltip` | Всплывающая подсказка элемента (строка или `{ru,en}`) |
| `visible: false` | Скрыть (синоним: `hidden: true`) |
| `enabled: false` | Сделать недоступным (синоним: `disabled: true`) |
| `readOnly: true` | Только чтение |
| `on: [...]` | События с автоименованием обработчиков |
| `handlers: {...}` | Явное задание имён обработчиков: `{"OnChange": "МоёИмя"}` |
| `events: {...}` | Обработчики событий: `{ "OnChange": "ИмяОбработчика" }`. Тот же формат, что у событий формы. Значение `null` → имя обработчика сгенерируется автоматически |
### Допустимые имена событий (`on`)
### Допустимые имена событий (`events`)
Компилятор предупреждает о неизвестных событиях. Имена регистрозависимы — используйте точно как указано.
@@ -116,7 +116,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
| Ключ | Описание | Пример |
|------|----------|--------|
| `path` | DataPath — привязка к данным | `"Объект.Организация"` |
| `titleLocation` | Размещение заголовка | `"none"`, `"left"`, `"top"` |
| `titleLocation` | Размещение заголовка | `"none"`, `"left"`, `"right"`, `"top"`, `"bottom"`, `"auto"` |
| `multiLine: true` | Многострочное поле | текстовое поле, комментарий |
| `passwordMode: true` | Режим пароля (звёздочки) | поле ввода пароля |
| `choiceButton: true` | Кнопка выбора ("...") | ссылочное поле |
@@ -179,13 +179,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
### Группа (group)
Значение ключа задаёт ориентацию: `"horizontal"`, `"vertical"`, `"alwaysHorizontal"`, `"alwaysVertical"`, `"collapsible"`.
Значение ключа задаёт **ориентацию**: `"vertical"`, `"horizontalIfPossible"`, `"alwaysHorizontal"`.
| Ключ | Описание |
|------|----------|
| `behavior` | Поведение группы: `"collapsible"` (сворачиваемая) / `"popup"` (всплывающая). Опустить = обычная |
| `showTitle: true` | Показывать заголовок группы |
| `united: false` | Левый край полей ввода выравнивается только в пределах этой группы (по умолчанию `true` — сквозное выравнивание по самому длинному заголовку, в т.ч. с соседними группами) |
| `collapsed: true` | Только для `"group": "collapsible"` — группа создаётся свёрнутой |
| `collapsed: true` | Для `behavior: "collapsible"` / `"popup"` — группа создаётся свёрнутой |
| `representation` | `"none"`, `"normal"`, `"weak"`, `"strong"` |
| `children: [...]` | Вложенные элементы |
@@ -202,8 +203,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
| `height` | Высота в строках таблицы |
| `header: false` | Скрыть шапку |
| `footer: true` | Показать подвал |
| `commandBarLocation` | `"None"`, `"Top"`, `"Auto"` |
| `searchStringLocation` | `"None"`, `"Top"`, `"Auto"` |
| `commandBarLocation` | `"None"`, `"Top"`, `"Bottom"`, `"Auto"` |
| `searchStringLocation` | `"None"`, `"Top"`, `"Bottom"`, `"CommandBar"`, `"PullFromTop"`, `"Auto"` |
| `choiceMode: true` | Режим выбора (для форм выбора) |
| `initialTreeView` | `"ExpandTopLevel"` и др. (иерархические списки) |
| `enableDrag: true` | Разрешить перетаскивание |
@@ -240,6 +241,15 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
]}
```
### Картинка-поле (picField)
PictureField, привязанный к булеву/числу, рисует иконку только при заданном `valuesPicture`:
| Ключ | Описание |
|------|----------|
| `valuesPicture` | Ref картинки значения: `"StdPicture.Favorites"`, `"CommonPicture.X"` |
| `loadTransparent: true` | Скрыть кадр «нет значения» |
### Страницы (pages + page)
| Ключ (pages) | Описание |
@@ -431,7 +441,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
"events": { "OnCreateAtServer": "ПриСозданииНаСервере" },
"elements": [
{ "group": "horizontal", "name": "ГруппаФайл", "children": [
{ "input": "ИмяФайла", "path": "ИмяФайла", "title": "Файл", "inputHint": "Выберите файл...", "choiceButton": true, "on": ["StartChoice"] },
{ "input": "ИмяФайла", "path": "ИмяФайла", "title": "Файл", "inputHint": "Выберите файл...", "choiceButton": true, "events": { "StartChoice": "ИмяФайлаНачалоВыбора" } },
{ "check": "ПерваяСтрокаЗаголовок", "path": "ПерваяСтрокаЗаголовок" }
]},
{ "input": "Результат", "path": "Результат", "multiLine": true, "height": 8, "readOnly": true, "title": "Лог" },
@@ -491,8 +501,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
"title": "Просмотр данных",
"elements": [
{ "group": "horizontal", "name": "Фильтр", "children": [
{ "input": "Период", "path": "Период", "on": ["OnChange"] },
{ "input": "Организация", "path": "Организация", "on": ["OnChange"] }
{ "input": "Период", "path": "Период", "events": { "OnChange": "ПериодПриИзменении" } },
{ "input": "Организация", "path": "Организация", "events": { "OnChange": "ОрганизацияПриИзменении" } }
]},
{ "table": "Данные", "path": "Данные", "changeRowSet": true, "columns": [
{ "input": "Дата", "path": "Данные.Дата" },
@@ -513,10 +523,26 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
}
```
## Продвинутые конструкции (по необходимости)
Описанного выше хватает для большинства форм. Под конкретную задачу подгрузите файл из `references/`:
- `dynamic-list.md` — форма списка: источник, отбор, сортировка, группировки, параметры запроса
- `appearance.md` — условное и статическое оформление элементов (цвета/шрифты/рамки)
- `choice-params.md` — параметры и связи выбора у полей ввода
- `command-interface.md` — командный интерфейс формы
- `roles-access.md` — пользовательская видимость и доступ по ролям
- `companion-panels.md` — контент расширенной подсказки и контекстного меню
- `special-fields.md` — поля документа/датчика (HTML, текст, индикатор, ползунок)
- `charts.md` — диаграммы и планировщик
- `report-form.md` — свойства формы отчёта
- `type-system-advanced.md` — наборы и составные типы
- `table-advanced.md` — расширенные свойства таблиц
- `layout-advanced.md` — тонкая компоновка и геометрия
## Автогенерация
- **Companion-элементы**: ContextMenu, ExtendedTooltip и др. создаются автоматически
- **Обработчики событий**: `"on": ["OnChange"]``ОрганизацияПриИзменении`
- **Namespace**: все 17 namespace-деклараций
- **ID**: последовательная нумерация, AutoCommandBar = id="-1"
- **Unknown keys**: выводится предупреждение о нераспознанных ключах
@@ -0,0 +1,123 @@
# Оформление
Два независимых механизма: **оформление элемента** (постоянные цвета/шрифт/граница на конкретном элементе) и **условное оформление формы** (`conditionalAppearance` — правила, применяемые при выполнении условия).
## Оформление элемента (цвета / шрифты / граница)
Свойства задаются прямо на элементе. Применимо к полям (`input`/`check`/`radio`/`labelField`/`picField`/`calendar`), декорациям (`label`/`picture`), кнопкам (`button`), группам (`group`/`columnGroup`), страницам (`page`/`pages`), попапам (`popup`) и таблицам (`table`). Каждое свойство необязательно.
| Ключ | Что задаёт |
|------|-----------|
| `textColor` | Цвет текста |
| `backColor` | Цвет фона |
| `borderColor` | Цвет рамки |
| `font` | Шрифт |
| `border` | Граница |
| `titleTextColor` / `titleBackColor` / `titleFont` | Цвет текста / цвет фона / шрифт заголовка колонки (`labelField`, колонки таблицы); у `page`/`pages`/`popup``titleTextColor`/`titleFont` заголовка страницы/попапа |
| `footerTextColor` / `footerBackColor` / `footerFont` | Цвет текста / цвет фона / шрифт подвала колонки |
Те же свойства доступны и через словарь `appearance` элемента — под русскими именами параметров платформы: `ЦветТекста`, `ЦветФона`, `ЦветРамки`, `Шрифт`, `Граница`, `ЦветТекстаЗаголовка`, `ЦветФонаЗаголовка`, `ШрифтЗаголовка`, `ЦветТекстаПодвала`, `ЦветФонаПодвала`, `ШрифтПодвала`. Это та же запись, что и в правилах условного оформления (ниже) и в `appearance` поля дин-списка.
### Цвет
Строка в одной из форм:
| Форма | Значение |
|-------|----------|
| `web:Имя` | Цвет из web-палитры, напр. `web:Red`, `web:FireBrick`, `web:HoneyDew` |
| `win:Имя` | Системный цвет Windows, напр. `win:MenuBar`, `win:ButtonText`, `win:DisabledText` |
| `style:ИмяСтиля` | Ссылка на элемент стиля конфигурации/платформы, напр. `style:FormBackColor`, `style:BorderColor` |
| `#RRGGBB` | RGB-hex, напр. `#FF0000` |
Имя должно существовать в своей палитре (несуществующий web-/win-цвет или ссылка на отсутствующий `style:`-элемент — ошибка загрузки формы).
### Шрифт (`font` / `titleFont` / `footerFont`)
- Строка `"style:ИмяСтиля"` — шрифт из элемента стиля. Минимальная форма.
- Объект — задаются только нужные атрибуты:
| Ключ | Назначение |
|------|-----------|
| `ref` | Ссылка на стиль (`"style:X"`) или системный шрифт (`"sys:…"`) |
| `faceName` | Имя гарнитуры (для собственного шрифта) |
| `height` | Размер |
| `bold` / `italic` / `underline` / `strikeout` | `true`/`false` — начертание |
| `scale` | Масштаб, % |
| `kind` | `Absolute` (собственный шрифт — с `faceName`+`height`) / `WindowsFont` (системный — с `ref:"sys:…"`) |
```json
{ "label": "Внимание!", "textColor": "web:FireBrick",
"font": { "faceName": "Arial", "height": 12, "bold": true, "kind": "Absolute", "scale": 100 } }
```
### Граница (`border`)
- Строка `"style:ИмяСтиля"` (или объект `{ "ref": "style:X" }`) — граница из стиля.
- Объект `{ "width": N, "style": "..." }` — собственная граница. `style` — один из: `Single`, `Double`, `Underline`, `DoubleUnderline`, `Overline`, `Embossed`, `Indented`, `WithoutBorder`.
```json
{ "input": "Цена", "path": "Объект.Цена", "textColor": "#FF0000",
"borderColor": "style:BorderColor", "border": { "width": 1, "style": "Single" } }
{ "labelField": "Код", "titleTextColor": "web:HoneyDew", "border": "style:ControlBorder" }
```
## Условное оформление формы (`conditionalAppearance`)
Форменный ключ верхнего уровня — массив правил. Каждое правило применяет оформление к перечисленным полям, когда выполняется его условие.
```json
"conditionalAppearance": [
{ "selection": ["ОбычноеПоле"], "filter": ["ЧисловоеПоле > 100"],
"appearance": { "ЦветФона": "style:FormBackColor" },
"presentation": { "ru": "Подсветка", "en": "Highlight" } }
]
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `selection` | array | Имена форматируемых полей формы |
| `filter` | array | Условие применения (грамматика — ниже) |
| `appearance` | object | Словарь «параметр платформы: значение» |
| `presentation` | string / object | Подпись правила в списке настроек |
| `use` | bool | `false` — правило отключено |
| `viewMode` | string | Режим отображения настройки |
| `userSettingID` | string | Идентификатор пользовательской настройки; `"auto"` — сгенерировать |
### filter
Та же грамматика, что в отборе списка — shorthand `"Поле оператор значение @флаги"` или объект:
```json
"filter": [
"Статус = 3",
{ "field": "Сумма", "op": ">=", "value": 1000 },
{ "group": "Or", "items": [ "Просрочено = true", "Заблокирован = true" ] }
]
```
- **Операторы:** `=` `<>` `>` `>=` `<` `<=`, `in` / `notIn`, `inHierarchy`, `contains` / `notContains`, `beginsWith` / `notBeginsWith`, `like` / `notLike` (`%`-шаблон), `filled` / `notFilled`.
- **Флаги:** `@off` (отключён), `@user`, `@quickAccess`; `_` = пустое значение.
- **Группа:** `{ "group": "And"|"Or"|"Not", "items": [...], "use"? }`.
- **Дата-значение:** ISO-дата `"2024-01-01T00:00:00"` — фиксированная дата; именованный относительный период — строкой `"BeginningOfThisWeek"` с `"valueType": "v8:StandardBeginningDate"` (варианты `BeginningOfThisDay`/`BeginningOfThisWeek`/`BeginningOfThisMonth`/`BeginningOfThisYear`/…).
### appearance
Словарь «параметр платформы: значение». Имена параметров — русские: `ЦветТекста`, `ЦветФона`, `Шрифт`, `Граница`, `Текст`, `Заголовок`, `Формат`, `ВидимостьЭлемента`, `Доступность` и другие параметры оформления компоновки.
Значения:
- **Цвет** (`ЦветТекста`/`ЦветФона`/…) и **шрифт** (`Шрифт`) — те же формы, что в оформлении элемента выше (`web:`/`win:`/`style:`/`#RRGGBB`; шрифт — строка `"style:X"` или объект).
- **Текстовые параметры** (`Текст`/`Заголовок`/`Формат`) — по форме значения:
- голая строка → нелокализованный литерал (`""` → пустое значение);
- объект `{ "ru": "...", "en": "..." }` → локализуемая строка;
- объект `{ "field": "путь" }` → ссылка на поле компоновки.
```json
"conditionalAppearance": [
{ "selection": ["Остаток"], "filter": ["Остаток < 0"],
"appearance": { "ЦветТекста": "web:Red", "Шрифт": { "bold": true } } },
{ "selection": ["Комментарий"], "filter": ["Комментарий notFilled"],
"appearance": { "Текст": { "ru": "— нет данных —" }, "ЦветТекста": "win:DisabledText" } }
]
```
> Условное оформление **самого дин-списка** задаётся не здесь, а в `settings.conditionalAppearance` реквизита-списка — см. `references/dynamic-list.md`.
@@ -0,0 +1,143 @@
# Диаграммы, диаграмма Ганта, планировщик
Поле-диаграмма (`chart` / `ganttChart`), поле-планировщик (`planner`) и дендрограмма выводят значение из реквизита соответствующего типа. Конструкция всегда двойная:
1. **Реквизит** chart/planner-типа (несёт данные и, при необходимости, design-time конфиг).
2. **Элемент** формы, привязанный к реквизиту через `path`.
Минимум — реквизит нужного типа плюс элемент с тем же `path`:
```json
"attributes": [ { "name": "Диаграмма", "type": "d5p1:Chart" } ],
"items": [ { "chart": "ПолеДиаграммы", "path": "Диаграмма" } ]
```
Реквизит, заполняемый в коде (без встроенной настройки), достаточно объявить типом — элемент привязывается и работает.
## Типы реквизита и элемента
| Элемент | Ключ типа | Тип реквизита | Что несёт элемент дополнительно |
|---------|-----------|---------------|---------------------------------|
| Диаграмма | `chart` | `d5p1:Chart` | — |
| Диаграмма Ганта | `ganttChart` | `d5p1:GanttChart` | `ganttTable` — вложенная таблица (см. ниже) |
| Планировщик | `planner` | `pl:Planner` | — |
| График. схема | `graphicalSchema` | `d5p1:FlowchartContextType` | `edit`, `warningOnEditRepresentation` |
| Период | `periodField` | `v8:StandardPeriod` | — |
| Дендрограмма | `dendrogram` | — | — |
Имя элемента — значение ключа (`"chart": "ПолеДиаграммы"`); `path` — короткое имя реквизита.
### Элемент диаграммы Ганта (`ganttTable`)
У поля Ганта внутри лежит полноценная таблица — задаётся ключом `ganttTable` (та же грамматика, что у обычной `table`):
```json
{ "ganttChart": "Ганта", "path": "Ганта",
"ganttTable": { "table": "ТаблицаГанта", "path": "Ганта", "height": 3 } }
```
## Design-time конфиг диаграммы (`chart`)
Реквизит типа `d5p1:Chart` / `d5p1:GanttChart` может нести встроенную настройку диаграммы — объект `chart` на реквизите. Платформа всегда пишет полный набор свойств (~127: тип, серии, легенда, заголовок, шкалы, цвета, шрифты, оси), поэтому **авторинг с нуля непрактичен** — возьмите рабочую диаграмму за основу и правьте смысловое ядро.
Ключи `chart` = канонические имена свойств диаграммы; задавайте только те, что меняете:
```json
{ "name": "Диаграмма", "type": "d5p1:Chart", "chart": {
"chartType": "Line",
"isSeriesDesign": true, "realSeriesCount": "2",
"realSeriesData": [
{ "id": "1", "color": "auto", "line": {"width":2,"gap":false,"style":"Solid"},
"marker": "Auto", "text": "Серия 1", "strIsChanged": false, "isExpand": false,
"isIndicator": false, "colorPriority": false }
],
"isShowTitle": true, "title": "Продажи",
"isShowLegend": true, "legendPlacement": "Bottom",
"paletteKind": "Auto"
} }
```
Смысловое ядро для правки:
| Ключ | Назначение |
|------|------------|
| `chartType` | Тип: `Line` / `Pie` / `Bar` / `Histogram` / `Column` / `Area` / … |
| `realSeriesData` | Массив серий — объекты `{ id, text, color, line, marker, … }` |
| `isShowTitle` + `title` | Показ и текст заголовка |
| `isShowLegend` + `legendPlacement` | Показ и расположение легенды (`Bottom` / `Right` / …) |
| `paletteKind` | Палитра (`Auto` / …) |
| `bkgColor` / `labelsColor` / … | Базовые цвета |
Формы значений внутри `chart`:
- **Цвета** — verbatim: `auto`, `style:ИмяСтиля`, `web:Red`, `#hex`.
- **`line`** — `{ width, gap, style }` (стиль линии: `Solid` / …).
- **`border`** — `{ width, style }`.
- **`font`** — `{ kind: "AutoFont" }` либо атрибуты шрифта.
- **Локализуемые строки** (`title`, `vsFormat`, `lbFormat`, `labelFormat`, серия `text`, …) — голая строка либо `{ "ru": "…", "en": "…" }`.
- **Области** (`elementsChart` / `elementsLegend` / `elementsTitle`) — `{ left, right, top, bottom }`.
- **Серии** (`realSeriesData` / `realExSeriesData`) — массивы объектов.
Любое из ~127 свойств переопределяется по каноническому имени; остальное оставляйте дефолтным (не указывайте — берётся из основы).
### Диаграмма Ганта (`d5p1:GanttChart`)
Реквизит типа `d5p1:GanttChart` использует **тот же** ключ `chart`. Внутри — вложенный полный `chart`-блок плюс гант-специфика (`points` / `series` / `timeScale` / `drawEmpty` / …). Так же берите рабочую диаграмму Ганта за основу.
> **Ограничение.** Диаграммы (Chart/Gantt) с заполненными **точками/осями** (`realPointData` / `realDataItems`, заполненные `valuesAxis` / `pointsAxis`) генерик-движком не поддержаны — это редкий вариант. Частые дашборд-диаграммы и диаграммы Ганта (серии / легенда / оформление / шкалы) поддержаны полностью.
## Design-time конфиг планировщика (`planner`)
Реквизит типа `pl:Planner` несёт встроенную настройку планировщика — объект `planner`. Компилятор подставляет умолчания для пропущенных ключей, поэтому авторинг может быть кратким:
```json
{ "name": "Планировщик", "type": "pl:Planner", "planner": {
"items": [
{ "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00",
"borderColor": "auto", "backColor": "auto", "deleted": false, "editMode": "EnableEdit" }
],
"period": { "begin": "2026-06-09T00:00:00", "end": "2026-06-09T23:59:59" },
"displayCurrentDate": true, "itemsTimeRepresentation": "BeginTime",
"timeScale": { "placement": "Left", "levels": [ { "measure": "Hour", "interval": 1 } ] }
} }
```
Минимум — один `item`:
```json
"planner": { "items": [ { "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00" } ] }
```
| Ключ `planner` | Тип | Назначение |
|----------------|-----|------------|
| `items` | array | Элементы расписания. Поля элемента: `text`, `tooltip`, `begin`, `end`, `value`, `borderColor`, `backColor`, `textColor`, `font`, `border`, `replacementDate`, `deleted` (bool), `editMode` (`EnableEdit` / …), `id` (необязательно — авто-GUID), `textFormatted` |
| `dimensions` | array | Измерения (разрезы) планировщика. Поля: `value` (объект разреза — ссылка `Enum.X.EnumValue.Y` / `Справочник.X`; опустить → пусто), `text` (заголовок), `borderColor`, `backColor`, `textColor`, `font`, `textFormatted`, `elements`. `elements` — элементы измерения, рекурсивны (могут нести вложенные `elements`): `value`, `text`, цвета, `font`, `showOnlySubordinatesAreas` (bool), `textFormatted` |
| `period` | object | Отображаемый период `{ begin, end }` (необязательно) |
| `timeScale` | object | Шкала времени (см. ниже) |
| `borderColor` / `backColor` / `textColor` / `lineColor` | color | Цвета (умолч. `auto`) |
| `font` | font | Шрифт (умолч. `{ kind: "AutoFont" }`) |
| `border` | border | Рамка `{ width, style }` |
| `beginOfRepresentationPeriod` / `endOfRepresentationPeriod` | dateTime | Период представления |
| `displayCurrentDate` / `displayWrapHeaders` / `displayTimeScaleWrapHeaders` / `alignElementsOfTimeScale` | bool | Флаги отображения |
| `timeScaleWrapHeadersFormat` | ML | Формат перенесённых заголовков шкалы |
| `timeScaleWrapBeginIndent` / `timeScaleWrapEndIndent` | int | Отступы переноса шкалы |
| `periodicVariantUnit` / `periodicVariantRepetition` | value / int | Единица и кратность периодического варианта |
| `itemsTimeRepresentation` | value | Представление времени элементов (`BeginTime` / …) |
| `itemsBehaviorWhenSpaceInsufficient` / `newItemsTextType` / `fixDimensionsHeader` / `fixTimeScaleHeader` | value | Поведение элементов и заголовков |
| `autoMinColumnWidth` / `autoMinRowHeight` | bool | Авто-минимум размеров |
| `minColumnWidth` / `minRowHeight` | int | Минимальные размеры |
Шкала времени (`timeScale`):
```json
"timeScale": {
"placement": "Left",
"levels": [ { "measure": "Hour", "interval": 1 } ]
}
```
Ключи: `placement`, `levels` (массив уровней), `transparent`, `backColor`, `textColor`, `currentLevel`. Уровень: `measure` (`Hour` / `Day` / …), `interval`, `show`, `line` (`{ width, gap, style }`), `scaleColor`, `dayFormatRule`, `format` (ML), `labels` (`{ ticks }`), `backColor`, `textColor`, `showPereodicalLabels`.
Формы значений в `planner` те же, что у диаграммы: цвета verbatim (`auto` / `style:X` / `web:Red` / `#hex`); шрифт `{ kind: "AutoFont" }` либо ref-строка; граница `{ width, style }`; ML-форматы — строка или `{ "ru": …, "en": … }`.
> **Ограничение.** Привязка элемента расписания к элементам измерений (`item.dimensionValues`) пока всегда пустая. Сами измерения (`dimensions`) задавать можно.
@@ -0,0 +1,73 @@
# Параметры выбора и связь по типу
Свойства поля ввода (`input`), управляющие выбором значения: чем ограничен список выбора и каким будет тип значения. Имена параметров — строки 1С как есть (`"Отбор.Х"`).
```json
{ "input": "Контрагент", "path": "Объект.Контрагент",
"choiceParameters": [
{ "name": "Отбор.Активный", "value": true },
{ "name": "Отбор.ВидПродукции", "value": ["Enum.Виды.Агрохимикат", "Enum.Виды.Пестицид"] }
],
"choiceParameterLinks": [
{ "name": "Отбор.Организация", "dataPath": "Объект.Организация" },
{ "name": "Отбор.Тип", "dataPath": "Объект.Тип", "valueChange": "DontChange" }
],
"typeLink": { "dataPath": "Объект.ЗначениеДата", "linkItem": 0 }
}
```
## Параметры выбора (`choiceParameters`)
Фиксированные значения параметров выбора, отбирающие список значений независимо от данных формы. Массив объектов `{ name, value }`:
- `name` — имя параметра (`"Отбор.Активный"`).
- `value` — значение. Допустимы: bool, число, строка, ISO-дата (`"2020-01-01T00:00:00"`), ссылка-путь (`Enum.X.Y`, `Catalog.X`). **Массив** значений задаёт фиксированный массив.
Короткая форма — строки `"name=value"`; значение с запятыми становится массивом, `true`/`false` → bool, число → число, остальное → строка/ссылка:
```json
"choiceParameters": [
"Отбор.Активный=true",
"Отбор.ВидПродукции=Enum.Виды.Агрохимикат, Enum.Виды.Пестицид"
]
```
## Связи параметров выбора (`choiceParameterLinks`)
Параметры выбора, значение которых берётся из **другого поля формы** (а не задано фиксированно). Типовой случай — отбор списка договоров по выбранному контрагенту. Массив объектов `{ name, dataPath, valueChange? }`:
- `name` — имя параметра выбора.
- `dataPath` — путь к полю формы, чьё значение подставляется в параметр.
- `valueChange` — что делать с уже выбранным значением при смене источника: `Clear` (очистить, необязательно — поведение по умолчанию) / `DontChange` (не менять).
```json
{ "input": "Договор", "path": "Объект.Договор",
"choiceParameterLinks": [
{ "name": "Отбор.Владелец", "dataPath": "Объект.Контрагент" }
]
}
```
Короткая форма — строки `"name=dataPath"`, опциональный хвост `:Clear` / `:DontChange`:
```json
"choiceParameterLinks": [ "Отбор.Организация=Объект.Организация", "Отбор.Тип=Объект.Тип:DontChange" ]
```
## Связь по типу (`typeLink`)
Тип значения поля определяется другим полем формы (напр. поле «Значение» субконто, тип которого задаётся выбранным видом субконто). Объект `{ dataPath, linkItem }`:
- `dataPath` — путь к полю, задающему тип.
- `linkItem` — индекс элемента связи (необязательно, по умолчанию `0`).
```json
"typeLink": { "dataPath": "Объект.ВидСубконто", "linkItem": 0 }
```
Короткая форма — строка `"dataPath"` либо `"dataPath#linkItem"`:
```json
"typeLink": "Объект.ВидСубконто"
"typeLink": "Объект.ВидСубконто#1"
```
@@ -0,0 +1,86 @@
# Командный интерфейс формы
Форменный ключ `commandInterface` управляет расстановкой команд по двум панелям формы:
- `commandBar` — командная панель формы;
- `navigationPanel` — панель навигации.
Указывать нужно **только команды, у которых меняется расстановка по умолчанию** (видимость, группа, порядок). Команды, которые платформа размещает автоматически и без изменений, в блок не включают.
```json
"commandInterface": {
"commandBar": [
{ "command": "Form.Command.Печать", "defaultVisible": false, "group": "FormCommandBarImportant",
"visible": { "common": false, "roles": { "Бухгалтер": true } } },
"CommonCommand.История"
],
"navigationPanel": {
"important": [ { "command": "CommonCommand.СвязанныеДокументы", "defaultVisible": false, "visible": false } ],
"seeAlso": [ { "command": "CommonCommand.Заметки", "defaultVisible": false, "visible": false } ]
}
}
```
## Элемент-команда
Каждый элемент панели — объект, либо строка-shorthand (= голый `command` со всеми остальными свойствами по умолчанию):
```json
"CommonCommand.История"
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `command` | string | Ссылка на команду дословно: `CommonCommand.X`, `Document.X.StandardCommand.Y`, `Form.Command.X`, `Form.StandardCommand.OK`, `"0"` (пустой / разделитель) |
| `type` | string | `Auto` (по умолчанию, необязательно) или `Added` |
| `defaultVisible` | bool | Видимость по умолчанию. На практике задаётся только `false` — чтобы скрыть команду, которая иначе видна |
| `visible` | bool / object | Видимость с исключениями по ролям: `bool` либо `{ "common": bool, "roles": { "Имя": bool } }` |
| `group` | string | Группа размещения дословно: предопределённая (`FormCommandBarImportant`, `FormNavigationPanelGoTo`, …), именованная (`CommandGroup.X`) или GUID-группа расширения |
| `index` | int | Порядок команды внутри группы |
| `attribute` | string | Путь реквизита для элемента панели навигации |
## Две формы записи панели
Панель можно описать **плоским массивом** или **деревом по группам** — выбирайте любую.
**Плоский массив** — каждый элемент при необходимости несёт собственный `group`:
```json
"commandBar": [
{ "command": "Form.Command.Печать", "group": "FormCommandBarImportant", "defaultVisible": false },
{ "command": "CommonCommand.История", "group": "FormCommandBarImportant", "index": 1 }
]
```
**Дерево** — объект `{ группа: [команды] }`; группа берётся из ключа, элементы её не повторяют:
```json
"navigationPanel": {
"important": [ "CommonCommand.СвязанныеДокументы" ],
"goTo": [ { "command": "Document.Заказ.StandardCommand.Movements", "defaultVisible": false, "visible": false } ],
"seeAlso": [ "CommonCommand.Заметки" ]
}
```
Ключи-группы дерева зависят от панели:
- `navigationPanel`: `important`, `goTo`, `seeAlso` (можно по-русски — `важное`, `перейти`, `смТакже`);
- `commandBar`: `important`, `createBasedOn`;
- любой другой ключ (`CommandGroup.X` или GUID) подставляется в группу дословно.
## Скрыть видимую команду
Самый частый случай — убрать команду, которую платформа показывает по умолчанию:
```json
"commandBar": [
{ "command": "Form.Command.Печать", "defaultVisible": false, "visible": false }
]
```
Показать команду только некоторым ролям:
```json
{ "command": "Form.Command.Печать", "defaultVisible": false,
"visible": { "common": false, "roles": { "Бухгалтер": true } } }
```
@@ -0,0 +1,131 @@
# Companion-панели и расширенная подсказка элемента
Любой элемент формы может нести свой собственный контент в трёх companion-свойствах: расширенную подсказку (`extendedTooltip`), командную панель (`commandBar`) и контекстное меню (`contextMenu`). Все три задаются ключами прямо на объекте элемента.
```jsonc
{ "table": "Список", "path": "Список",
"commandBar": { "children": [ ] },
"contextMenu": { "children": [ ] },
"extendedTooltip": "Двойной клик открывает карточку" }
```
## Расширенная подсказка (`extendedTooltip`)
Подсказка-надпись рядом с элементом. Две формы записи.
**Текст-форма** — просто текст подсказки:
```jsonc
"extendedTooltip": "Укажите ИНН контрагента"
"extendedTooltip": { "ru": "Сумма с НДС", "en": "Amount incl. VAT" }
"extendedTooltip": { "text": "Всего <b>с НДС</b>", "formatted": true }
```
- строка — ru-текст;
- `{ "ru": …, "en": … }` — многоязычный (как `title`);
- `{ "text": …, "formatted": true }` — форматированный текст (inline-разметка 1С: `<b>…</>`, `<i>`, `<u>`, `<color web:Red>…</>`, `<bgColor …>`, `<font …>`, `<fontSize …>`, `<link URL>…</>`, `<img …>`; закрывающий тег — `</>`). `formatted` нужен только когда текст содержит такую разметку.
**Own-content форма** — объект с раскладкой/оформлением/флагами, когда подсказке нужны размеры, цвет, гиперссылка и т.п.:
```jsonc
"extendedTooltip": {
"text": "Перейти к инструкции",
"hyperlink": true,
"textColor": "web:Blue",
"events": { "URLProcessing": "ПодсказкаОбработкаНавигационнойСсылки" }
}
```
Ключи own-content объекта (все необязательны):
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `text` | string/ML | Текст подсказки (с `formatted` — форматированный) |
| `formatted` | bool | Интерпретировать inline-разметку в `text` |
| `tooltip` | string/ML | Всплывающая подсказка самой расширенной подсказки (редко; ≠ обычному `tooltip` элемента) |
| `hyperlink` | bool | Сделать подсказку гиперссылкой |
| `visible` / `enabled` | bool | Видимость / доступность подсказки |
| `width` / `height` | number | Размеры |
| `maxWidth` / `autoMaxWidth` | number / bool | Максимальная ширина / авто-максимум |
| `titleHeight` | number | Высота заголовка |
| `horizontalStretch` | bool | Горизонтальное растяжение |
| `verticalAlign` | string | Вертикальное выравнивание |
| `textColor` / `font` | string/object | Цвет текста / шрифт (см. `references/appearance.md`) |
| `events` | object | Обработчики событий подсказки, напр. `{ "URLProcessing": "Имя" }` у гиперссылочной подсказки |
## Командная панель (`commandBar`)
Собственная командная панель элемента (обычно таблицы или группы).
**Значение** — массив или объект:
```jsonc
"commandBar": [ { "button": "Создать", "command": "СоздатьЭлемент" } ]
"commandBar": {
"autofill": false,
"horizontalAlign": "Right",
"children": [
{ "button": "Создать", "command": "СоздатьЭлемент" },
{ "buttonGroup": "Печать", "children": [ ] }
]
}
```
- массив `[ … ]` — краткая запись для `{ "children": [ … ] }`;
- объект — `children` плюс необязательные `autofill` и `horizontalAlign`.
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `children` | array | Содержимое панели — обычная грамматика кнопок (см. основную инструкцию) |
| `autofill` | bool | `false` — подавить автозаполнение панели стандартными командами. Необязательно (по умолчанию панель автозаполняется) |
| `horizontalAlign` | string | Горизонтальное выравнивание содержимого: `Left` / `Center` / `Right`. Необязательно |
`children` — кнопки: `button` (с `command` / `commandName` / `stdCommand`), `buttonGroup`, `popup` — как в основной инструкции по кнопкам.
> Для таблицы динамического списка панель по умолчанию подавлена (чтобы не дублировать командную панель формы). Чтобы оставить автозаполняемую панель у самой таблицы — задайте `commandBar: { "autofill": true }`.
## Контекстное меню (`contextMenu`)
Собственное контекстное меню элемента. Грамматика та же, что у `commandBar`, но без `horizontalAlign`.
```jsonc
"contextMenu": [ { "button": "Карта маршрута", "commandName": "CommonCommand.КартаМаршрута" } ]
"contextMenu": {
"autofill": false,
"children": [
{ "button": "Скопировать ссылку", "command": "СкопироватьСсылку" }
]
}
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `children` | array | Пункты меню — обычная грамматика кнопок |
| `autofill` | bool | `false` — подавить автозаполнение меню. Необязательно |
## Пример: таблица со своим меню и инфо-баннером
```jsonc
{ "table": "Заказы", "path": "Объект.Заказы",
"extendedTooltip": {
"text": "Строки с просрочкой выделены <color web:FireBrick>красным</>",
"formatted": true
},
"commandBar": {
"autofill": false,
"horizontalAlign": "Right",
"children": [
{ "button": "Добавить", "command": "ДобавитьЗаказ" },
{ "button": "Удалить", "command": "УдалитьЗаказ" }
]
},
"contextMenu": {
"children": [
{ "button": "Открыть документ", "command": "ОткрытьЗаказ" },
{ "buttonGroup": "Экспорт", "children": [
{ "button": "В Excel", "command": "ВыгрузитьВExcel" } ] }
]
} }
```
@@ -0,0 +1,144 @@
# Динамический список
Реквизит с `type: "DynamicList"` (обычно `main: true`) — основа формы списка. Объект `settings` описывает источник данных и настройки списка. Минимум — указать источник:
```json
{ "name": "Список", "type": "DynamicList", "main": true,
"settings": { "mainTable": "Catalog.Контрагенты" } }
```
К списку привязывается таблица-элемент (`table`), ссылающаяся на реквизит через `path` — см. основную инструкцию.
## Источник данных
Два взаимоисключающих режима:
**Таблично-ориентированный** — основная таблица метаданных:
```json
"settings": { "mainTable": "Catalog.Контрагенты" }
```
**Запросный** — произвольный запрос:
```json
"settings": {
"query": "ВЫБРАТЬ Т.Ссылка, Т.Наименование, Т.Сумма ИЗ Документ.Заказ КАК Т ГДЕ Т.Сумма > &Порог",
"mainTable": "Document.Заказ"
}
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `mainTable` | string | Основная таблица (`Catalog.X` / `Document.X` / …). Можно вместе с `query` |
| `query` | string | Текст запроса. Поддерживает `@file.sql` (путь к файлу запроса рядом с JSON) |
| `keyType` | string | Запросный список без `mainTable`: тип ключа набора — `FieldValue` / `RowKey` / `RowNumber` |
| `keyFields` | array | Поля ключа набора (для `keyType` без `mainTable`) |
Параметры запроса (`&Имя`) задаются в `parameters` (ниже).
`"dynamicDataRead": false` отключает динамическое считывание (список читается обычным запросом, без фонового обновления) — нужно для тяжёлых/агрегатных запросов.
## Параметры запроса (`parameters`)
Значения для `&параметров` текста запроса. Shorthand `"Имя [Заголовок]: тип = Значение"` (всё кроме имени необязательно) либо объект:
```json
"settings": {
"query": "… ГДЕ Т.Артикул = &Артикул И Т.Цена ПОДОБНО &Маска",
"parameters": [
"Артикул",
"Маска: string = %",
{ "name": "ВидЦен", "valueListAllowed": true },
{ "name": "Период", "type": "dateTime" }
]
}
```
Ключи объекта: `name`, `title`, `type` (грамматика типов — см. основную инструкцию), `value`, `valueListAllowed` (разрешить список значений), `availableValues` (`[{ value, presentation }]`), `expression`, `use`.
## Значения параметров в настройках (`dataParameters`)
Предустановленные значения параметров на уровне настроек списка. Shorthand `"Имя = Значение"` или объект `{ parameter, value?, use?, viewMode? }`:
```json
"dataParameters": [ "Организация = _", "ВидЦен" ]
```
## Поля набора (`fields`)
Обычно поля выводятся из источника сами — `fields` нужен **только чтобы переопределить** свойства отдельного поля:
```json
"fields": [
{ "field": "Сумма", "title": "Сумма, руб", "appearance": { "Формат": "ЧДЦ=2" } },
{ "field": "Остаток", "valueType": "number(15,2)" }
]
```
Ключи поля: `field`, `dataPath`, `title`, `valueType`, `appearance` (как в условном оформлении), `presentationExpression`, `inputParameters` (связь по параметрам выбора), `typeLink` (`{ field, linkItem }` — связь по типу, напр. субконто).
## Вычисляемые поля (`calculatedFields`)
Поля, считаемые выражением. Shorthand `"Имя [Заголовок]: тип = Выражение"`:
```json
"calculatedFields": [
"Метка = Code + \" \" + Description",
"Маржа [Маржа, руб]: number(15,2) = Цена - Закупка"
]
```
Объектная форма — для `presentationExpression` / `orderExpression`:
```json
{ "dataPath": "Сорт", "expression": "Code", "title": "Сорт",
"valueType": "string(10)", "presentationExpression": "Code" }
```
## Отбор (`filter`)
Shorthand `"Поле оператор значение @флаги"` или объект:
```json
"filter": [
"Организация = _ @off @user",
"Сумма > 1000",
{ "field": "Дата", "op": ">=", "value": "2024-01-01T00:00:00" },
{ "group": "Or", "items": [ "Статус = 1", "Статус = 2" ] }
]
```
- **Операторы:** `=` `<>` `>` `>=` `<` `<=`, `in` / `notIn`, `inHierarchy`, `contains` / `notContains`, `beginsWith` / `notBeginsWith`, `like` / `notLike` (`%`-шаблон), `filled` / `notFilled`.
- **Флаги:** `@off` (отключён), `@user` (в пользовательских настройках), `@quickAccess`; `_` = пустое значение.
- **Группа:** `{ group: "And"|"Or"|"Not", items: [...] }`.
- **Дата-значение:** ISO-дата `"2024-01-01T00:00:00"` — фиксированная дата. Именованный относительный период — строкой с типом: `{ "value": "BeginningOfThisWeek", "valueType": "v8:StandardBeginningDate" }` (варианты `BeginningOfThisDay`/`BeginningOfThisWeek`/`BeginningOfThisMonth`/`BeginningOfThisYear`/…).
## Сортировка (`order`)
Строка `"Поле"` (по возр.) / `"Поле desc"`, либо объект `{ field, direction? }`. `"Auto"` — автосортировка:
```json
"order": [ "Дата desc", "Наименование", "Auto" ]
```
## Группировка строк (`grouping`)
Линейная цепочка уровней (внешний → внутренний). Шорткат `>` или массив:
```json
"grouping": "Контрагент > Договор"
"grouping": [ "Контрагент", { "field": "Дата", "groupType": "Hierarchy" } ]
```
Ключи уровня-объекта: `field`, `groupType` (`Items` / `Hierarchy`).
## Условное оформление (`conditionalAppearance`)
```json
"conditionalAppearance": [
{ "filter": [ "Просрочено = true" ], "appearance": { "ЦветТекста": "web:Red" } }
]
```
`filter` — та же грамматика, что выше. `appearance` — словарь «параметр платформы: значение» (`ЦветТекста`, `ЦветФона`, `Шрифт`, `Текст`, `Формат`, …). Значение `Текст`/`Заголовок`/`Формат`: голая строка — нелокализованный литерал; `{ru,en}` — локализуемая строка; `{ field: "путь" }` — ссылка на поле. Подробнее об оформлении — `references/appearance.md`.
@@ -0,0 +1,111 @@
# Продвинутая раскладка
Тонкая настройка размещения элемента внутри родителя сверх базовой геометрии (`width`/`height`/`horizontalStretch`/`verticalStretch`/`visible`/`enabled` и ориентации групп/страниц — они в основной инструкции). Все ключи ниже задаются прямо на элементе и **необязательны** — без них действует поведение платформы по умолчанию.
## Выравнивание внутри родителя
Различают **выравнивание самого элемента** в отведённой ему ячейке и **выравнивание содержимого** элемента.
| Ключ | Значения | Что выравнивает |
|------|----------|-----------------|
| `groupHorizontalAlign` | `Left` / `Center` / `Right` | Положение **элемента** по горизонтали в родительской группе (когда элемент у́же доступного места) |
| `groupVerticalAlign` | `Top` / `Center` / `Bottom` | Положение **элемента** по вертикали в родительской группе |
| `horizontalAlign` | `Left` / `Center` / `Right` | Выравнивание **содержимого** (текста/значения) внутри самого элемента |
| `verticalAlign` | `Top` / `Center` / `Bottom` | Выравнивание содержимого по вертикали внутри элемента |
`group*Align` отвечает на вопрос «куда сдвинуть нерастянутый элемент в его ячейке», `horizontalAlign`/`verticalAlign` — «как разместить текст внутри элемента». Это разные оси настройки, их часто комбинируют.
```json
{ "button": "ОК", "groupHorizontalAlign": "Right" }
{ "input": "Сумма", "path": "Объект.Сумма", "horizontalAlign": "Right" }
{ "label": "Итого", "groupHorizontalAlign": "Center", "horizontalAlign": "Center" }
```
## Ограничение максимального размера
По умолчанию растягивающийся элемент имеет авто-вычисляемый предел ширины/высоты. Чтобы задать жёсткий предел или вовсе снять авто-предел:
| Ключ | Значения | Назначение |
|------|----------|-----------|
| `maxWidth` | число | Жёсткий максимум ширины элемента |
| `maxHeight` | число | Жёсткий максимум высоты элемента |
| `autoMaxWidth` | `false` | Отключить авто-предел ширины (элемент тянется без ограничения сверху) |
| `autoMaxHeight` | `false` | Отключить авто-предел высоты |
`autoMaxWidth: false` нужен, например, для широкого многострочного поля или растянутого по всей форме поля ввода, чтобы платформа не «прижимала» его к авто-пределу. Указывают именно отклонение от дефолта; обычное значение `true` писать не нужно.
```json
{ "input": "Комментарий", "path": "Объект.Комментарий", "multiLine": true,
"horizontalStretch": true, "autoMaxWidth": false }
{ "input": "Поиск", "path": "СтрокаПоиска", "horizontalStretch": true, "maxWidth": 600 }
```
## Поведение при вводе и активации
| Ключ | Значения | Назначение |
|------|----------|-----------|
| `skipOnInput` | `true` / `false` | Пропускать элемент при обходе по Enter/Tab (фокус через него не проходит). Указывают явно, в т.ч. `false` чтобы вернуть в обход поле, которое платформа пропустила бы |
| `defaultItem` | `true` | Элемент получает фокус по умолчанию при открытии формы (поле/таблица для немедленного ввода) |
```json
{ "input": "Идентификатор", "path": "Объект.Идентификатор", "skipOnInput": true }
{ "input": "Штрихкод", "path": "Штрихкод", "defaultItem": true }
```
`skipOnInput: true` — для служебных/расчётных полей, которые видны, но не редактируются вводом с клавиатуры в общем потоке. `defaultItem: true` ставят на одном элементе формы — точке, с которой пользователь начнёт работу.
## Перетаскивание
| Ключ | Значения | Назначение |
|------|----------|-----------|
| `enableStartDrag` | `true` | Разрешить начинать перетаскивание из элемента (источник drag-n-drop) |
Для таблиц приём/перемещение строк управляется ключами таблицы (`enableDrag`, `changeRowOrder`) — см. основную инструкцию; `enableStartDrag` — общий низкоуровневый флаг «этот элемент может быть источником перетаскивания».
## Закрепление колонки в таблице (`fixingInTable`)
Свойство поля-колонки внутри таблицы: закрепить колонку у края, чтобы она не уходила при горизонтальной прокрутке.
| Значения |
|----------|
| `None` (по умолчанию — не закреплена) / `Left` / `Right` |
```json
{ "table": "Товары", "path": "Объект.Товары", "columns": [
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "fixingInTable": "Left" },
{ "input": "Количество", "path": "Объект.Товары.Количество" },
{ "input": "Сумма", "path": "Объект.Товары.Сумма", "fixingInTable": "Right" } ] }
```
Закрепляют ключевые колонки (идентифицирующую слева, итоговую справа), чтобы они оставались видны при прокрутке широкой таблицы.
## Ячейки колонок: шапка и подвал
Для поля-колонки внутри таблицы (и `columnGroup`) — размещение в шапке/подвале и выравнивание текста ячеек. Применять только к элементам внутри `columns` таблицы.
| Ключ | Значения | Назначение |
|------|----------|-----------|
| `showInHeader` | `true` / `false` | Показывать колонку в шапке таблицы |
| `showInFooter` | `true` / `false` | Показывать колонку в подвале (нужно для итогов; подвал самой таблицы включается `footer: true`) |
| `headerHorizontalAlign` | `Left` / `Right` / `Center` / `Auto` | Выравнивание текста в шапке колонки |
| `footerHorizontalAlign` | `Left` / `Right` / `Center` | Выравнивание текста в подвале колонки |
| `autoCellHeight` | `true` / `false` | Авто-высота ячейки (перенос содержимого на несколько строк) |
```json
{ "table": "Товары", "path": "Объект.Товары", "footer": true, "columns": [
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "autoCellHeight": true },
{ "input": "Сумма", "path": "Объект.Товары.Сумма",
"headerHorizontalAlign": "Right", "showInFooter": true, "footerHorizontalAlign": "Right" } ] }
```
## Адаптивная важность (`displayImportance`)
| Значения |
|----------|
| `VeryHigh` / `High` / `Usual` / `VeryLow` / `Low` |
Приоритет элемента при адаптивной перекомпоновке формы на узких/мобильных экранах: элементы с меньшей важностью сворачиваются/прячутся первыми. Применимо к любому элементу.
```json
{ "input": "Комментарий", "path": "Объект.Комментарий", "displayImportance": "Low" }
```
@@ -0,0 +1,79 @@
# Форма отчёта
Форма, подключённая к объекту-отчёту (`Report`). Кроме обычных свойств формы у неё есть несколько свойств в `properties`, связывающих форму с механизмом компоновки (СКД): куда выводится результат, где данные расшифровки, какого она типа. Все они задаются в блоке `properties` верхнего уровня.
```json
"properties": {
"reportFormType": "Main",
"reportResult": "РезультатОтчета",
"detailsData": "ДанныеРасшифровки"
}
```
Ни одно из этих свойств не обязательно — указывайте только те, что нужны конкретной форме.
## Тип формы отчёта (`reportFormType`)
Роль формы в составе отчёта:
| Значение | Назначение |
|----------|-----------|
| `Main` | Основная форма отчёта (результат + настройки) |
| `Settings` | Форма настроек |
| `Variant` | Форма варианта |
```json
"reportFormType": "Main"
```
## Привязка к компоновке
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `reportResult` | string | Имя реквизита-результата формы (табличный документ, куда выводится отчёт) |
| `detailsData` | string | Имя реквизита данных расшифровки |
| `variantAppearance` | string | Имя реквизита оформления варианта |
Значение каждого ключа — имя реквизита формы (а не путь к данным). Реквизит с таким именем должен присутствовать в `attributes` формы.
## Группа пользовательских настроек (`customSettingsFolder`)
Группа-элемент формы, в которую генерируются пользовательские настройки компоновщика. Задаётся **по имени** элемента-группы:
```json
"customSettingsFolder": "ГруппаПользовательскихНастроек"
```
## Прочие свойства компоновки
Редкие, задавайте только при явной необходимости:
| Ключ | Значения | Назначение |
|------|----------|-----------|
| `autoShowState` | `Auto`, `DontShow`, `ShowOnComposition` | Автопоказ состояния формирования |
| `reportResultViewMode` | `Auto` | Режим просмотра результата |
| `viewModeApplicationOnSetReportResult` | `Auto` | Применение режима просмотра при установке результата |
## Реалистичный пример
Основная форма отчёта со СКД: реквизит-результат, данные расшифровки и группа пользовательских настроек.
```json
{
"properties": {
"reportFormType": "Main",
"reportResult": "РезультатОтчета",
"detailsData": "ДанныеРасшифровки",
"customSettingsFolder": "ГруппаПользовательскихНастроек"
},
"attributes": [
{ "name": "РезультатОтчета", "type": "SpreadsheetDocument" },
{ "name": "ДанныеРасшифровки", "type": "DataCompositionDetailsData" }
],
"elements": [
{ "group": "vertical", "name": "ГруппаПользовательскихНастроек" },
{ "spreadsheet": "РезультатОтчета", "path": "РезультатОтчета",
"titleLocation": "none" }
]
}
```
@@ -0,0 +1,73 @@
# Доступ по ролям
Единый механизм платформы для разграничения по ролям: задаётся общее значение для всех ролей плюс исключения для конкретных ролей. Один и тот же формат значения у четырёх ключей — каждый на своём владельце:
| Ключ | Владелец | Смысл |
|------|----------|-------|
| `userVisible` | элемент формы | пользовательская видимость элемента |
| `view` | реквизит формы | право просмотра |
| `edit` | реквизит формы | право редактирования |
| `use` | команда формы | доступность команды |
Ключ необязателен: его отсутствие = полный доступ для всех ролей.
## Значение
Две формы (одинаковы для всех четырёх ключей):
**Скаляр** `true` / `false` — общее значение для всех ролей, без исключений:
```json
{ "input": "Поле", "userVisible": false }
```
**Объект** `{ "common": <bool>, "roles": { "ИмяРоли": <bool>, … } }` — общее значение `common` плюс явные исключения по ролям:
```json
{ "name": "Реквизит",
"edit": { "common": false, "roles": { "ПолныеПрава": true } } }
```
Роль, **не указанная** в `roles`, наследует `common`. Указанная — задаёт явный `true`/`false` (может и совпадать с `common`).
## Имя роли
Ключи в `roles` — имена ролей конфигурации (`ПолныеПрава`, `Бухгалтер`, …).
## Примеры
Элемент скрыт у всех пользователей:
```json
{ "input": "Комментарий", "userVisible": false }
```
Реквизит не виден никому и редактируется только одной ролью:
```json
{ "name": "СуммаБонуса",
"view": false,
"edit": { "common": false, "roles": { "ПолныеПрава": true } } }
```
Поле доступно для просмотра всем, но редактируемо только администратору:
```json
{ "name": "Статус",
"view": true,
"edit": { "common": false, "roles": { "Администратор": true } } }
```
Команда недоступна по умолчанию, разрешена только бухгалтеру:
```json
{ "name": "ПровестиЗакрытие",
"use": { "common": false, "roles": { "Бухгалтер": true } } }
```
Обратный случай — доступно всем, кроме одной роли:
```json
{ "name": "РедактироватьЦену",
"edit": { "common": true, "roles": { "Кладовщик": false } } }
```
@@ -0,0 +1,109 @@
# Спец-поля «документ/датчик»
Поля для отображения специальных данных: табличный документ, HTML, текст, форматированный документ, индикатор, ползунок. Каждое привязывается к реквизиту своего платформенного типа.
Структурно это обычные поля — поддерживают общий скелет поля (`path`, `title`, `titleLocation`, флаги `readOnly`/`enabled`/`visible`, `layout`, оформление, события). Ниже — только ключ `type` (имя элемента задаётся значением ключа) и собственные скаляры каждого семейства. Все скаляры необязательны.
| Ключ типа | Тип реквизита |
|-----------|---------------|
| `spreadsheet` | `mxl:SpreadsheetDocument` (ТабличныйДокумент) |
| `html` | `string` |
| `textDoc` | `d5p1:TextDocument` (ТекстовыйДокумент) |
| `formattedDoc` | `fd:FormattedDocument` (ФорматированныйДокумент) |
| `progressBar` | число |
| `trackBar` | число |
## spreadsheet — поле табличного документа
Просмотр/редактирование табличного документа (отчёт, печатная форма).
```json
{ "spreadsheet": "ТаблицаОтчета", "path": "ТаблицаОтчета",
"titleLocation": "none", "readOnly": true,
"output": "Disable", "protection": true }
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `output` | string | Использование вывода: `Enable` / `Disable` |
| `protection` | bool | Защита от изменений |
| `edit` | bool | Разрешить редактирование |
| `showGrid` | bool | Показывать сетку |
| `showHeaders` | bool | Показывать заголовки строк/колонок |
| `showGroups` | bool | Показывать группировки |
| `showRowAndColumnNames` | bool | Показывать имена строк и колонок |
| `showCellNames` | bool | Показывать имена ячеек |
| `verticalScrollBar` / `horizontalScrollBar` | string | Режим полос прокрутки |
| `viewScalingMode` | string | Режим масштабирования просмотра |
| `selectionShowMode` | string | Режим отображения выделения |
| `pointerType` | string | Тип указателя |
| `enableDrag` / `enableStartDrag` | bool | Разрешить перетаскивание / начало перетаскивания |
## html — поле HTML-документа
Просмотр HTML. Реквизит — строка (содержит HTML-текст или адрес).
```json
{ "html": "Просмотр", "path": "СодержимоеHTML", "titleLocation": "none",
"output": "Enable", "warningOnEditRepresentation": false }
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `output` | string | Использование вывода: `Enable` / `Disable` |
| `warningOnEditRepresentation` | bool | Предупреждать при изменении представления |
## textDoc — поле текстового документа
Просмотр/редактирование текстового документа.
```json
{ "textDoc": "Текст", "path": "ТекстДокумента", "editMode": "Edit" }
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `editMode` | string | Режим редактирования (напр. `Edit` / `View`) |
## formattedDoc — поле форматированного документа
Просмотр/редактирование форматированного документа.
```json
{ "formattedDoc": "Описание", "path": "ФорматированноеОписание", "editMode": "Edit" }
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `editMode` | string | Режим редактирования (напр. `Edit` / `View`) |
## progressBar — поле индикатора
Индикатор прогресса. Реквизит — числовой.
```json
{ "progressBar": "Прогресс", "path": "Прогресс",
"minValue": 0, "maxValue": 100, "showPercent": true }
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `minValue` / `maxValue` | число | Минимальное / максимальное значение |
| `showPercent` | bool | Показывать проценты |
## trackBar — поле ползунка
Регулятор-ползунок. Реквизит — числовой.
```json
{ "trackBar": "Масштаб", "path": "Масштаб",
"minValue": 20, "maxValue": 400, "markingStep": 20 }
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `minValue` / `maxValue` | число | Минимальное / максимальное значение |
| `step` | число | Шаг изменения |
| `largeStep` | число | Крупный шаг |
| `markingStep` | число | Шаг разметки |
| `markingAppearance` | string | Оформление разметки |
@@ -0,0 +1,132 @@
# Таблица — продвинутые возможности
Базовый элемент таблицы (`type: "table"`, колонки, основные свойства) описан в основной инструкции, раздел «Таблица (table)». Здесь — продвинутые возможности: дополнения командной панели, специфика таблицы динамического списка и неочевидные свойства/режимы.
## Представление (`representation`)
Как таблица рисует строки:
```json
{ "table": "Список", "path": "Список", "representation": "Tree" }
```
`List` — плоский список (по умолчанию), `Tree` — дерево, `HierarchicalList` — иерархический список (группы + элементы на одном уровне).
Для дерева/иерархии управляйте раскрытием уровней через `initialTreeView` (`ExpandTopLevel` / `ExpandAllLevels` / `NoExpand`).
## Выделение и текущая строка
| Ключ | Значения | Назначение |
|------|----------|-----------|
| `selectionMode` | `SingleRow` / `MultiRow` | Режим выделения строк |
| `multipleChoice` | bool | Разрешить множественный выбор (для форм выбора) |
| `currentRowUse` | `DontUse` / `Use` / `SelectionPresentation` / `SelectionPresentationAndChoice` / `Choice` | Использование текущей строки таблицы |
```json
{ "table": "Список", "path": "Список", "selectionMode": "MultiRow", "multipleChoice": true }
```
## Поиск при вводе (`searchOnInput`)
Поведение встроенного поиска при наборе текста в таблице:
```json
{ "table": "Список", "path": "Список", "searchOnInput": "Use" }
```
`Auto` (по умолчанию) / `Use` (искать) / `DontUse` (не искать).
Где располагать сами элементы поиска — управляется `searchStringLocation` / `viewStatusLocation` / `searchControlLocation` (`None` / `Top` / `Bottom` / `CommandBar` / `Auto`).
## Прочие свойства таблицы
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `useAlternationRowColor` | bool | Чередование цвета строк |
| `verticalLines` / `horizontalLines` | bool | Линии сетки (укажите `false`, чтобы скрыть) |
| `markIncomplete` | bool | Автоотметка незаполненных ячеек |
| `heightInTableRows` | int | Высота элемента в строках (отдельно от `height`) |
| `autoInsertNewRow` | bool | Автодобавление новой строки при вводе в последнюю |
| `rowsPicture` | string \| object | Картинка строк. Ссылка (`"CommonPicture.X"`, `"abs:..."`) либо объект `{ src, loadTransparent?, transparentPixel? }` |
| `tooltipRepresentation` | string | Режим показа подсказки таблицы: `None`, `Button`, `ShowBottom`, `ShowTop`, `ShowLeft`, `ShowRight`, `ShowAuto`, `Balloon` |
## Фиксация колонки (`fixingInTable`)
Свойство **колонки** (на `input` / `labelField` / `check` / `picField` внутри `columns`), а не самой таблицы. Закрепляет колонку у края при горизонтальной прокрутке:
```json
{ "table": "Товары", "path": "Объект.Товары", "columns": [
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "fixingInTable": "Left" },
{ "input": "Количество", "path": "Объект.Товары.Количество" }
]}
```
`Left` / `Right` / `None`.
## Исключённые команды (`excludedCommands`)
Убрать стандартные команды редактора таблицы (кнопки добавления/перемещения/сортировки):
```json
{ "table": "Товары", "path": "Объект.Товары",
"excludedCommands": [ "Add", "Delete", "MoveUp", "SortListAsc" ] }
```
Свойство работает на любом поле и на уровне формы; для таблицы значимы команды вида `Add` / `Delete` / `MoveUp` / `MoveDown` / `SortListAsc` / `SortListDesc`.
## Дополнения командной панели (`additions`)
Дополнения — это «представления» встроенного поиска таблицы:
- `searchString` — отображение строки поиска,
- `viewStatus` — состояние просмотра,
- `searchControl` — управление поиском.
Каждое дополнение — полноценный элемент (полный набор свойств поля). Размещать их можно двумя способами.
**(1) Стандартные дополнения** генерирует платформа на уровне таблицы. В DSL указывайте **только отклонения** от стандартного вида — через карту `additions` (ключ = тип дополнения):
```json
{ "table": "Список", "path": "Список",
"additions": { "viewStatus": { "horizontalLocation": "left" } } }
```
**(2) Кастомное дополнение**, размещённое прямо в командной панели — обычный элемент в `commandBar` с ключом-типом:
```json
{ "table": "Список", "path": "Список", "commandBar": [
{ "searchString": "ПоискСписка", "source": "Список", "width": 15, "horizontalStretch": true }
]}
```
- Тип-ключ: `searchString` / `viewStatus` / `searchControl`.
- `source` — имя таблицы-источника; необязательно, по умолчанию = имя родительской таблицы.
- `horizontalLocation`: `auto` (по умолчанию) / `left` / `right`. Применимо и к обычным элементам командных панелей.
- Прочие свойства как у поля: `title`, `visible`, `userVisible`, `enabled`, `tooltip`, оформление, `width` / `maxWidth` / `autoMaxWidth` / `horizontalStretch` / `groupHorizontalAlign` и др.
## Таблица динамического списка
Когда `path` таблицы указывает на реквизит `type: "DynamicList"` (см. `references/dynamic-list.md`), доступен блок специфичных свойств. Указывайте **только отличия** от умолчания.
| Ключ | Тип | Умолчание | Назначение |
|------|-----|-----------|-----------|
| `rowPictureDataPath` | string | картинка осн. таблицы | Путь к картинке строки. `""` — подавить картинку |
| `rowsPicture` | string | — | Картинка строк (`"CommonPicture.X"`) |
| `autoRefresh` | bool | `false` | Автообновление списка |
| `autoRefreshPeriod` | int | `60` | Период автообновления, сек |
| `updateOnDataChange` | string | `Auto` | Обновлять при изменении данных: `Auto` / `DontUpdate` |
| `choiceFoldersAndItems` | string | `Items` | Что выбирать: `Items` / `Folders` / `FoldersAndItems` |
| `restoreCurrentRow` | bool | `false` | Восстанавливать текущую строку при обновлении |
| `showRoot` | bool | `true` | Показывать корень |
| `allowRootChoice` | bool | `false` | Разрешить выбор корня |
| `allowGettingCurrentRowURL` | bool | `true` | Разрешить получение URL текущей строки |
| `userSettingsGroup` | string | — | Группа пользовательских настроек (привязка к одноимённой группе настроек) |
```json
{ "table": "Список", "path": "Список",
"representation": "Tree",
"rowPictureDataPath": "Список.DefaultPicture",
"choiceFoldersAndItems": "FoldersAndItems",
"allowRootChoice": true,
"updateOnDataChange": "DontUpdate" }
```
@@ -0,0 +1,77 @@
# Продвинутые конструкции типов
Примитивы (`string(n)`, `number(p,s)`, `boolean`, `date`/`dateTime`, …) и одиночные ссылки (`CatalogRef.Контрагенты`, `DocumentRef.Заказ`, `EnumRef.X`, …) описаны в основной инструкции. Здесь — типы, которые нельзя выразить одним именем: составные типы, наборы типов и платформенные наборы ссылок.
Любая из этих конструкций пишется в поле `type` реквизита, реквизита-параметра или поля.
## Составные типы
Несколько типов на одном реквизите — части перечисляются через разделитель `" | "` (можно `+`). Реквизит сможет принимать значение любого из перечисленных типов:
```json
{ "name": "Плательщик",
"type": "CatalogRef.Организации | CatalogRef.ИндивидуальныеПредприниматели" }
```
Смешивать можно типы из разных категорий — ссылки, примитивы, наборы типов:
```json
{ "name": "Источник",
"type": "CatalogRef.Контрагенты | DocumentRef.Заказ | string(150)" }
```
Каждая часть — самостоятельный токен из этого файла или из основной инструкции. Порядок частей произвольный.
## Наборы типов (TypeSet)
«Набор типов» подставляется вместо конкретного типа — это один токен, а не перечисление. Применимо и в составном типе как одна из частей.
| Токен `type` | Смысл |
|------|-------|
| `"DefinedType.ИмяТипа"` | определяемый тип конфигурации |
| `"Characteristic.ИмяПлана"` | тип значения характеристики (по плану видов характеристик) |
| `"AnyRef"` | любая ссылка |
| `"AnyIBRef"` | любая ссылка информационной базы |
Определяемый тип — реквизит принимает то, что задано в определяемом типе конфигурации (например `DefinedType.ДенежнаяСумма`):
```json
{ "name": "Сумма", "type": "DefinedType.ДенежнаяСумма" }
```
Характеристика — тип значения берётся из плана видов характеристик:
```json
{ "name": "Значение", "type": "Characteristic.ДополнительныеРеквизиты" }
```
## Платформенные наборы ссылок
«Голый» ссылочный токен **без `.Имя`** означает «любая ссылка этой категории объектов»:
| Токен `type` | Смысл |
|------|-------|
| `"CatalogRef"` | любая ссылка справочника |
| `"DocumentRef"` | любая ссылка документа |
| `"EnumRef"` | любая ссылка перечисления |
| `"ExchangePlanRef"` | любая ссылка плана обмена |
| `"TaskRef"` | любая ссылка задачи |
| `"BusinessProcessRef"` | любая ссылка бизнес-процесса |
| `"ChartOfCharacteristicTypesRef"` | любая ссылка плана видов характеристик |
| `"ChartOfAccountsRef"` | любая ссылка плана счетов |
| `"ChartOfCalculationTypesRef"` | любая ссылка плана видов расчёта |
Различие с одиночной ссылкой — только в наличии `.Имя`:
- `"CatalogRef.Валюты"` — конкретный справочник «Валюты»;
- `"CatalogRef"` — любой справочник.
```json
{ "name": "ЛюбойСправочник", "type": "CatalogRef" }
```
Эти наборы тоже комбинируются в составном типе:
```json
{ "name": "Объект", "type": "CatalogRef | DocumentRef" }
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
---
name: form-decompile
description: Декомпиляция управляемой формы 1С (Form.xml) в JSON-черновик в формате form-compile. Используй для scaffold новой формы по образцу или структурного рефакторинга. Не для точечных правок
argument-hint: <FormPath> [-OutputPath <out.json>]
disable-model-invocation: true
allowed-tools:
- Bash
- Read
- Write
- Glob
---
# /form-decompile — JSON-черновик из Form.xml управляемой формы
Читает Form.xml и эмитит компактный JSON в формате `form-compile`. **Результат — черновик**, а не обратимое представление: см. раздел «Что получаешь».
## Когда использовать
- **Scaffold новой формы по образцу** — взять существующую форму, получить JSON, поправить и скомпилировать в новую.
- **Структурный рефакторинг** — перебрать дерево элементов, реквизиты, команды.
## Когда **не** использовать
- **Точечные правки готовой формы** (добавить элемент, реквизит, команду) → `/form-edit`. Цикл «декомпиляция → правка JSON → компиляция» переписывает форму целиком, может терять непокрытые конструкции и даёт большой diff. `/form-edit` правит адресно.
## Параметры
| Параметр | Описание |
|----------|----------|
| `FormPath` | Путь к Form.xml (обязательный) |
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-decompile.ps1" -FormPath "<Form.xml>" -OutputPath "<out.json>"
```
## Что получаешь
JSON-черновик в формате `/form-compile`**не полное обратимое представление**: раундтрип `xml → json → xml` не гарантируется, часть конструкций DSL не покрывает и **теряет молча**.
Критичные конструкции (`ConditionalAppearance` со scope, design-time диаграммы/планировщики на реквизите, неизвестный тип элемента, не-Form root) → скрипт падает с ненулевым кодом и сообщением в stderr; для правок такой формы — `/form-edit`.
## Workflow
1. `/form-decompile <Form.xml> -OutputPath draft.json` — получить черновик.
2. Поправить JSON под задачу.
3. `/form-compile -JsonPath draft.json -OutputPath new/Form.xml` — собрать обратно.
4. `/form-validate` + `/form-info` — проверить результат.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+55 -4
View File
@@ -1,4 +1,4 @@
# form-edit v1.0 — Edit 1C managed form elements
# form-edit v1.1 — Edit 1C managed form elements
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -291,6 +291,16 @@ function Get-ElementName {
return "$($el.$typeKey)"
}
# Уникальность имён внутри JSON-определения (1С: своя коллекция — свой неймспейс).
function Assert-EditUnique {
param([string]$name, [hashtable]$seen, [string]$ctx)
if ($seen.ContainsKey($name)) {
Write-Host "[ERROR] Duplicate $ctx '$name' in JSON definition — names must be unique in 1C form"
exit 1
}
$seen[$name] = $true
}
$script:knownEvents = @{
"input" = @("OnChange","StartChoice","ChoiceProcessing","AutoComplete","TextEditEnd","Clearing","Creating","EditTextChange")
"check" = @("OnChange")
@@ -864,17 +874,31 @@ if ($def.elements -and $def.elements.Count -gt 0) {
# Detect indent level
$childIndent = Get-ChildIndent $targetCI
# Check for duplicate element names
# Имена элементов уникальны (требование 1С). Сначала — внутри самого JSON-определения
# (рекурсивно по children/columns).
$elemTypeKeys = @("group","input","check","label","labelField","table","pages","page","button","picture","picField","calendar","cmdBar","popup")
function Walk-ElemNames($el, [hashtable]$seen) {
$tk = $null
foreach ($k in $elemTypeKeys) { if ($el.$k -ne $null) { $tk = $k; break } }
if ($tk) { Assert-EditUnique -name (Get-ElementName -el $el -typeKey $tk) -seen $seen -ctx 'element name' }
if ($el.children) { foreach ($c in $el.children) { Walk-ElemNames $c $seen } }
if ($el.columns) { foreach ($c in $el.columns) { Walk-ElemNames $c $seen } }
}
$dslElemNames = @{}
foreach ($el in $def.elements) { Walk-ElemNames $el $dslElemNames }
# Затем — против уже существующих элементов формы (дубль = битый XML, форма не откроется)
foreach ($el in $def.elements) {
$typeKey = $null
foreach ($key in @("group","input","check","label","labelField","table","pages","page","button","picture","picField","calendar","cmdBar","popup")) {
foreach ($key in $elemTypeKeys) {
if ($el.$key -ne $null) { $typeKey = $key; break }
}
if ($typeKey) {
$elName = Get-ElementName -el $el -typeKey $typeKey
$existing = Find-Element $rootCI $elName
if ($existing) {
Write-Host "[WARN] Element '$elName' already exists in form (id=$($existing.GetAttribute('id')))"
Write-Host "[ERROR] Element '$elName' already exists in form (id=$($existing.GetAttribute('id'))) — element names must be unique"
exit 1
}
}
}
@@ -953,6 +977,22 @@ if ($def.attributes -and $def.attributes.Count -gt 0) {
$attrChildIndent = Get-ChildIndent $attrsSection
if (-not $attrChildIndent -or $attrChildIndent -eq "") { $attrChildIndent = "`t`t" }
# Уникальность имён реквизитов: внутри JSON-определения (+ колонки в пределах реквизита) и
# против уже существующих реквизитов формы.
$dslAttrNames = @{}
foreach ($attr in $def.attributes) {
Assert-EditUnique -name "$($attr.name)" -seen $dslAttrNames -ctx 'attribute name'
if ($attr.columns) {
$dslColNames = @{}
foreach ($col in $attr.columns) { Assert-EditUnique -name "$($col.name)" -seen $dslColNames -ctx "column name of '$($attr.name)'" }
}
$existingAttr = $attrsSection.SelectSingleNode("f:Attribute[@name='$($attr.name)']", $nsMgr)
if ($existingAttr) {
Write-Host "[ERROR] Attribute '$($attr.name)' already exists in form — attribute names must be unique"
exit 1
}
}
# Generate attribute fragments
$script:xml = New-Object System.Text.StringBuilder 2048
X "<_F $allNsDecl>"
@@ -1021,6 +1061,17 @@ if ($def.commands -and $def.commands.Count -gt 0) {
$cmdChildIndent = Get-ChildIndent $cmdsSection
if (-not $cmdChildIndent -or $cmdChildIndent -eq "") { $cmdChildIndent = "`t`t" }
# Уникальность имён команд: внутри JSON-определения и против существующих команд формы.
$dslCmdNames = @{}
foreach ($cmd in $def.commands) {
Assert-EditUnique -name "$($cmd.name)" -seen $dslCmdNames -ctx 'command name'
$existingCmd = $cmdsSection.SelectSingleNode("f:Command[@name='$($cmd.name)']", $nsMgr)
if ($existingCmd) {
Write-Host "[ERROR] Command '$($cmd.name)' already exists in form — command names must be unique"
exit 1
}
}
# Generate command fragments
$script:xml = New-Object System.Text.StringBuilder 1024
X "<_F $allNsDecl>"
+52 -3
View File
@@ -1,4 +1,4 @@
# form-edit v1.0 — Edit 1C managed form elements (Python port)
# form-edit v1.1 — Edit 1C managed form elements (Python port)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
@@ -365,6 +365,14 @@ def get_element_name(el, type_key):
return str(el[type_key])
def _assert_edit_unique(name, seen, ctx):
# Уникальность имён внутри JSON-определения (1С: своя коллекция — свой неймспейс).
if name in seen:
print(f"[ERROR] Duplicate {ctx} '{name}' in JSON definition — names must be unique in 1C form")
sys.exit(1)
seen.add(name)
known_events = {
"input": ["OnChange", "StartChoice", "ChoiceProcessing", "AutoComplete", "TextEditEnd", "Clearing", "Creating", "EditTextChange"],
"check": ["OnChange"],
@@ -988,7 +996,26 @@ if elements_list:
# Detect indent level
child_indent = get_child_indent(target_ci)
# Check for duplicate element names
# Имена элементов уникальны (требование 1С). Сначала — внутри самого JSON-определения
# (рекурсивно по children/columns).
def _walk_elem_names(el, seen):
tk = None
for key in ELEMENT_KEYS:
if key in el and el[key] is not None:
tk = key
break
if tk:
_assert_edit_unique(get_element_name(el, tk), seen, "element name")
for c in el.get("children", []):
_walk_elem_names(c, seen)
for c in el.get("columns", []):
_walk_elem_names(c, seen)
dsl_elem_names = set()
for el in elements_list:
_walk_elem_names(el, dsl_elem_names)
# Затем — против уже существующих элементов формы (дубль = битый XML, форма не откроется).
for el in elements_list:
type_key = None
for key in ELEMENT_KEYS:
@@ -999,7 +1026,8 @@ if elements_list:
el_name = get_element_name(el, type_key)
existing = find_element(root_ci, el_name) if root_ci is not None else None
if existing is not None:
print(f"[WARN] Element '{el_name}' already exists in form (id={existing.get('id')})")
print(f"[ERROR] Element '{el_name}' already exists in form (id={existing.get('id')}) — element names must be unique")
sys.exit(1)
# Remember starting element ID for companion counting
start_elem_id = next_elem_id
@@ -1055,6 +1083,19 @@ if attrs_list:
if not attr_child_indent:
attr_child_indent = "\t\t"
# Уникальность имён реквизитов: внутри JSON-определения (+ колонки в пределах реквизита) и
# против уже существующих реквизитов формы.
dsl_attr_names = set()
for attr in attrs_list:
_assert_edit_unique(str(attr["name"]), dsl_attr_names, "attribute name")
if attr.get("columns"):
dsl_col_names = set()
for col in attr["columns"]:
_assert_edit_unique(str(col["name"]), dsl_col_names, f"column name of '{attr['name']}'")
if attrs_section.find(f"f:Attribute[@name='{attr['name']}']", NS) is not None:
print(f"[ERROR] Attribute '{attr['name']}' already exists in form — attribute names must be unique")
sys.exit(1)
# Generate attribute fragments
xml_lines.clear()
X(f"<_F {ALL_NS_DECL}>")
@@ -1116,6 +1157,14 @@ if cmds_list:
if not cmd_child_indent:
cmd_child_indent = "\t\t"
# Уникальность имён команд: внутри JSON-определения и против существующих команд формы.
dsl_cmd_names = set()
for cmd in cmds_list:
_assert_edit_unique(str(cmd["name"]), dsl_cmd_names, "command name")
if cmds_section.find(f"f:Command[@name='{cmd['name']}']", NS) is not None:
print(f"[ERROR] Command '{cmd['name']}' already exists in form — command names must be unique")
sys.exit(1)
xml_lines.clear()
X(f"<_F {ALL_NS_DECL}>")
for cmd in cmds_list:
@@ -1,4 +1,4 @@
# form-validate v1.6 — Validate 1C managed form
# form-validate v1.7 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -155,7 +155,8 @@ if (-not $stopped) {
# --- Collect all elements with IDs ---
$elementIds = @{} # id -> name (element ID pool)
$elementIds = @{} # id -> name (element ID pool)
$elementNames = @{} # name -> id (имена элементов уникальны в пределах формы)
$allElements = @() # @{Name; Tag; Id; ParentName; Node}
function Collect-Elements {
@@ -185,6 +186,13 @@ function Collect-Elements {
} else {
$elementIds[$id] = $name
}
# Имена элементов уникальны (требование 1С)
if ($elementNames.ContainsKey($name)) {
Report-Error "Duplicate element name '$name': id=${id} and id=$($elementNames[$name])"
} else {
$elementNames[$name] = $id
}
}
# Recurse into ChildItems
@@ -229,6 +237,10 @@ foreach ($attr in $attrNodes) {
$attrName = $attr.GetAttribute("name")
$attrId = $attr.GetAttribute("id")
if ($attrName) {
# Имена реквизитов уникальны среди реквизитов (отдельный неймспейс от элементов)
if ($attrMap.ContainsKey($attrName)) {
Report-Error "Duplicate attribute name '$attrName': id=${attrId} and id=$($attrMap[$attrName].GetAttribute('id'))"
}
$attrMap[$attrName] = $attr
}
if ($attrId -and $attrId -ne "") {
@@ -241,6 +253,7 @@ foreach ($attr in $attrNodes) {
# Column IDs are a separate sub-pool per attribute — check uniqueness within parent
$colIds = @{}
$colNames = @{} # имена колонок уникальны в пределах своего реквизита
foreach ($col in $attr.SelectNodes("f:Columns/f:Column", $nsMgr)) {
$colId = $col.GetAttribute("id")
$colName = $col.GetAttribute("name")
@@ -251,6 +264,13 @@ foreach ($attr in $attrNodes) {
$colIds[$colId] = $colName
}
}
if ($colName) {
if ($colNames.ContainsKey($colName)) {
Report-Error "Duplicate column name '$colName' in '$attrName': id=${colId} and id=$($colNames[$colName])"
} else {
$colNames[$colName] = $colId
}
}
}
}
@@ -270,6 +290,10 @@ foreach ($cmd in $cmdNodes) {
$cmdName = $cmd.GetAttribute("name")
$cmdId = $cmd.GetAttribute("id")
if ($cmdName) {
# Имена команд уникальны среди команд (отдельный неймспейс)
if ($cmdMap.ContainsKey($cmdName)) {
Report-Error "Duplicate command name '$cmdName': id=${cmdId} and id=$($cmdMap[$cmdName].GetAttribute('id'))"
}
$cmdMap[$cmdName] = $cmd
}
if ($cmdId -and $cmdId -ne "") {
@@ -290,6 +314,20 @@ if (-not $stopped) {
}
}
# --- Collect parameters (separate name pool, без id) ---
$paramNames = @{} # name -> $true (имена параметров уникальны среди параметров)
foreach ($param in $root.SelectNodes("f:Parameters/f:Parameter", $nsMgr)) {
$paramName = $param.GetAttribute("name")
if ($paramName) {
if ($paramNames.ContainsKey($paramName)) {
Report-Error "Duplicate parameter name '$paramName'"
} else {
$paramNames[$paramName] = $true
}
}
}
# --- Check 4: Companion elements ---
# Define required companions per element type
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# form-validate v1.6 — Validate 1C managed form
# form-validate v1.7 — Validate 1C managed form
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -182,7 +182,8 @@ def main():
report_error("AutoCommandBar element missing")
# --- Collect all elements with IDs ---
element_ids = {} # id -> name
element_ids = {} # id -> name
element_names = {} # name -> id (имена элементов уникальны в пределах формы)
all_elements = [] # list of dicts {Name, Tag, Id, ParentName, Node}
def collect_elements(node, parent_name):
@@ -211,6 +212,12 @@ def main():
else:
element_ids[eid] = name
# Имена элементов уникальны (требование 1С)
if name in element_names:
report_error(f"Duplicate element name '{name}': id={eid} and id={element_names[name]}")
else:
element_names[name] = eid
child_items = child.find(f"{{{F_NS}}}ChildItems")
if child_items is not None:
collect_elements(child_items, name)
@@ -252,6 +259,9 @@ def main():
attr_name = attr.get("name", "")
attr_id = attr.get("id", "")
if attr_name:
# Имена реквизитов уникальны среди реквизитов (отдельный неймспейс от элементов)
if attr_name in attr_map:
report_error(f"Duplicate attribute name '{attr_name}': id={attr_id} and id={attr_map[attr_name].get('id', '')}")
attr_map[attr_name] = attr
if attr_id:
if attr_id in attr_ids:
@@ -261,6 +271,7 @@ def main():
# Column IDs uniqueness within parent
col_ids = {}
col_names = {} # имена колонок уникальны в пределах своего реквизита
columns = attr.find(f"{{{F_NS}}}Columns")
if columns is not None:
for col in columns.findall(f"{{{F_NS}}}Column"):
@@ -271,6 +282,11 @@ def main():
report_error(f"Duplicate column id={col_id} in '{attr_name}': '{col_name}' and '{col_ids[col_id]}'")
else:
col_ids[col_id] = col_name
if col_name:
if col_name in col_names:
report_error(f"Duplicate column name '{col_name}' in '{attr_name}': id={col_id} and id={col_names[col_name]}")
else:
col_names[col_name] = col_id
if not stopped:
if attr_ids:
@@ -289,6 +305,9 @@ def main():
cmd_name = cmd.get("name", "")
cmd_id = cmd.get("id", "")
if cmd_name:
# Имена команд уникальны среди команд (отдельный неймспейс)
if cmd_name in cmd_map:
report_error(f"Duplicate command name '{cmd_name}': id={cmd_id} and id={cmd_map[cmd_name].get('id', '')}")
cmd_map[cmd_name] = cmd
if cmd_id:
if cmd_id in cmd_ids:
@@ -300,6 +319,18 @@ def main():
if cmd_ids:
report_ok(f"Unique command IDs: {len(cmd_ids)} entries")
# --- Collect parameters (separate name pool, без id) ---
param_names = {} # name -> True (имена параметров уникальны среди параметров)
params_parent = root.find(f"{{{F_NS}}}Parameters")
if params_parent is not None:
for param in params_parent.findall(f"{{{F_NS}}}Parameter"):
param_name = param.get("name", "")
if param_name:
if param_name in param_names:
report_error(f"Duplicate parameter name '{param_name}'")
else:
param_names[param_name] = True
# --- Check 4: Companion elements ---
companion_rules = {
"InputField": ["ContextMenu", "ExtendedTooltip"],
+28 -1
View File
@@ -1,4 +1,4 @@
# meta-info v1.1 — Compact summary of 1C metadata object
# meta-info v1.2 — Compact summary of 1C metadata object
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory=$true)][Alias('Path')][string]$ObjectPath,
@@ -422,6 +422,22 @@ $objName = $props.SelectSingleNode("md:Name", $ns).InnerText
$synNode = $props.SelectSingleNode("md:Synonym", $ns)
$synonym = Get-MLText $synNode
# Presentations (type-choice dialogs show "Представление объекта" as the ref type name)
$objPresentation = Get-MLText $props.SelectSingleNode("md:ObjectPresentation", $ns)
$extObjPresentation = Get-MLText $props.SelectSingleNode("md:ExtendedObjectPresentation", $ns)
$listPresentation = Get-MLText $props.SelectSingleNode("md:ListPresentation", $ns)
$extListPresentation = Get-MLText $props.SelectSingleNode("md:ExtendedListPresentation", $ns)
# Reference (ref-typed) metadata objects — those with a ...Ref type
$refMdTypes = @("Catalog","Document","Enum","ChartOfAccounts","ChartOfCharacteristicTypes",
"ChartOfCalculationTypes","ExchangePlan","BusinessProcess","Task")
$isRefObject = $refMdTypes -contains $mdType
# Effective type presentation: ObjectPresentation -> Synonym -> Name
$typePresentation = if ($objPresentation) { $objPresentation }
elseif ($synonym) { $synonym }
else { $objName }
# --- Handle -Name drill-down ---
$drillDone = $false
if ($Name -and $childObjs) {
@@ -593,6 +609,17 @@ if (-not $drillDone) {
$header += " ==="
Out $header
# --- Type presentation (ref objects) ---
if ($isRefObject) {
Out "Представление типа: $typePresentation"
if ($Mode -eq "full") {
if ($objPresentation) { Out "Представление объекта: $objPresentation" }
if ($extObjPresentation) { Out "Расширенное представление объекта: $extObjPresentation" }
if ($listPresentation) { Out "Представление списка: $listPresentation" }
if ($extListPresentation) { Out "Расширенное представление списка: $extListPresentation" }
}
}
# --- Mode: brief ---
if ($Mode -eq "brief") {
# Attributes
+29 -1
View File
@@ -1,4 +1,4 @@
# meta-info v1.1 — Compact summary of 1C metadata object (Python port)
# meta-info v1.2 — Compact summary of 1C metadata object (Python port)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
@@ -477,6 +477,21 @@ obj_name = inner_text(find(props, "md:Name"))
syn_node = find(props, "md:Synonym")
synonym = get_ml_text(syn_node)
# Presentations (type-choice dialogs show "Представление объекта" as the ref type name)
obj_presentation = get_ml_text(find(props, "md:ObjectPresentation"))
ext_obj_presentation = get_ml_text(find(props, "md:ExtendedObjectPresentation"))
list_presentation = get_ml_text(find(props, "md:ListPresentation"))
ext_list_presentation = get_ml_text(find(props, "md:ExtendedListPresentation"))
# Reference (ref-typed) metadata objects — those with a ...Ref type
ref_md_types = {"Catalog", "Document", "Enum", "ChartOfAccounts",
"ChartOfCharacteristicTypes", "ChartOfCalculationTypes",
"ExchangePlan", "BusinessProcess", "Task"}
is_ref_object = md_type in ref_md_types
# Effective type presentation: ObjectPresentation -> Synonym -> Name
type_presentation = obj_presentation or synonym or obj_name
# ── Handle -Name drill-down ──────────────────────────────────
drill_done = False
@@ -636,6 +651,19 @@ if not drill_done:
header += " ==="
out(header)
# Type presentation (ref objects)
if is_ref_object:
out(f"Представление типа: {type_presentation}")
if mode == "full":
if obj_presentation:
out(f"Представление объекта: {obj_presentation}")
if ext_obj_presentation:
out(f"Расширенное представление объекта: {ext_obj_presentation}")
if list_presentation:
out(f"Представление списка: {list_presentation}")
if ext_list_presentation:
out(f"Расширенное представление списка: {ext_list_presentation}")
if mode == "brief":
# Attributes
attrs = get_attributes(child_objs) if child_objs is not None else []
+9 -1
View File
@@ -152,11 +152,19 @@ Shorthand: `"Имя [Заголовок]: тип = значение @флаги"
Флаги shorthand:
- `@autoDates` — добавляет к параметру StandardPeriod пару дат `НачалоПериода`/`КонецПериода`, вычисляемых из него. Используй их в тексте запроса как `&НачалоПериода`/`&КонецПериода`; пользователь выбирает только сам период. По умолчанию сам параметр получает `use=Always` и `denyIncompleteValues=true` (чтобы производные даты всегда были заполнены); в объектной форме можно явно переопределить.
- `@valueList``<valueListAllowed>true</valueListAllowed>` — разрешает передавать список значений
- `@valueList``<valueListAllowed>true</valueListAllowed>` — разрешает передавать список значений (при значении-списке ниже подразумевается автоматически)
- `@hidden` — скрытый параметр: `availableAsField=false` + исключается из `"dataParameters": "auto"`
Объектная форма: `title`, `hidden: true`, `valueListAllowed: true`, `availableAsField: false`, `denyIncompleteValues: true`, `use: "Always"`.
Значение-список: несколько значений по умолчанию через запятую в `значение` (для запятой внутри значения — кавычки `'...'`). В объектной форме — массив в `value`.
```json
"parameters": [
"Виды: ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные = ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"
]
```
Если значения по умолчанию нет — пропусти `=` в shorthand или укажи `"value": null` в объектной форме.
Список допустимых значений (availableValues):
@@ -1,4 +1,4 @@
# skd-compile v1.103 — Compile 1C DCS from JSON
# skd-compile v1.105 — Compile 1C DCS from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[string]$DefinitionFile,
@@ -475,6 +475,31 @@ function Parse-TotalShorthand {
# --- 7. Parameter shorthand parser ---
function Split-ValueListCsv {
# Split on top-level commas (respecting 'single'/"double" quotes), strip quotes,
# drop empties. No ':' handling — values may contain colons (dateTime).
param([string]$s)
$result = @()
if ($null -eq $s) { return ,$result }
$items = @()
$buf = New-Object System.Text.StringBuilder
$inQuote = $null
for ($i = 0; $i -lt $s.Length; $i++) {
$ch = $s[$i]
if ($inQuote) { [void]$buf.Append($ch); if ($ch -eq $inQuote) { $inQuote = $null } }
elseif ($ch -eq "'" -or $ch -eq '"') { $inQuote = $ch; [void]$buf.Append($ch) }
elseif ($ch -eq ',') { $items += $buf.ToString(); [void]$buf.Clear() }
else { [void]$buf.Append($ch) }
}
if ($buf.Length -gt 0) { $items += $buf.ToString() }
foreach ($raw in $items) {
$t = $raw.Trim()
if ($t.Length -ge 2 -and (($t[0] -eq "'" -and $t[-1] -eq "'") -or ($t[0] -eq '"' -and $t[-1] -eq '"'))) { $t = $t.Substring(1, $t.Length - 2) }
if ($t -ne "") { $result += $t }
}
return ,$result
}
function Parse-ParamShorthand {
param([string]$s)
@@ -509,7 +534,17 @@ function Parse-ParamShorthand {
$result.name = $Matches[1].Trim()
$result.type = Resolve-TypeStr ($Matches[2].Trim())
if ($Matches[4]) {
$result.value = $Matches[4].Trim()
$rhs = $Matches[4].Trim()
$items = Split-ValueListCsv $rhs
if ($items.Count -ge 2) {
# Multi-value default → list; valueListAllowed implied
$result.value = $items
$result.valueListAllowed = $true
} elseif ($items.Count -eq 1) {
$result.value = $items[0]
} else {
$result.value = $rhs
}
}
} else {
$result.name = $s.Trim()
@@ -2933,6 +2968,11 @@ function Parse-StructureShorthand {
$group | Add-Member -NotePropertyName "groupBy" -NotePropertyValue @($seg)
}
# Платформа в каждую группировку кладёт авто-поле выбора и авто-порядок;
# shorthand должен соответствовать ручному добавлению группировки в конфигураторе.
$group | Add-Member -NotePropertyName "selection" -NotePropertyValue @("Auto")
$group | Add-Member -NotePropertyName "order" -NotePropertyValue @("Auto")
if ($null -ne $innermost) {
$group | Add-Member -NotePropertyName "children" -NotePropertyValue @($innermost)
}
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# skd-compile v1.103 — Compile 1C DCS from JSON
# skd-compile v1.105 — Compile 1C DCS from JSON
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
@@ -325,6 +325,39 @@ def parse_total_shorthand(s):
# --- Parameter shorthand parser ---
def split_value_list_csv(s):
"""Split on top-level commas (respecting single/double quotes), strip quotes,
drop empties. No ':' handling values may contain colons (dateTime)."""
result = []
if s is None:
return result
items = []
buf = []
in_quote = None
for ch in s:
if in_quote:
buf.append(ch)
if ch == in_quote:
in_quote = None
elif ch in ("'", '"'):
in_quote = ch
buf.append(ch)
elif ch == ',':
items.append("".join(buf))
buf = []
else:
buf.append(ch)
if buf:
items.append("".join(buf))
for raw in items:
t = raw.strip()
if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')):
t = t[1:-1]
if t != "":
result.append(t)
return result
def parse_param_shorthand(s):
result = {'name': '', 'type': '', 'value': None, 'autoDates': False, 'title': None}
@@ -355,7 +388,16 @@ def parse_param_shorthand(s):
result['name'] = m.group(1).strip()
result['type'] = resolve_type_str(m.group(2).strip())
if m.group(4):
result['value'] = m.group(4).strip()
rhs = m.group(4).strip()
items = split_value_list_csv(rhs)
if len(items) >= 2:
# Multi-value default → list; valueListAllowed implied
result['value'] = items
result['valueListAllowed'] = True
elif len(items) == 1:
result['value'] = items[0]
else:
result['value'] = rhs
else:
result['name'] = s.strip()
@@ -2330,6 +2372,11 @@ def parse_structure_shorthand(s):
else:
group['groupBy'] = [seg]
# Платформа в каждую группировку кладёт авто-поле выбора и авто-порядок;
# shorthand должен соответствовать ручному добавлению группировки в конфигураторе.
group['selection'] = ['Auto']
group['order'] = ['Auto']
if innermost is not None:
group['children'] = [innermost]
innermost = group
@@ -1,4 +1,4 @@
# skd-decompile v0.89 — Decompile 1C DCS Template.xml to JSON DSL (draft)
# skd-decompile v0.90 — Decompile 1C DCS Template.xml to JSON DSL (draft)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -2494,9 +2494,22 @@ function Build-Structure {
return ,$items
}
# True when selection/order is just the single auto element ("Auto") that the
# compiler adds by default to every shorthand group — folding such a group back
# to shorthand is bit-perfect (Parse-StructureShorthand re-adds it on compile).
# Disabled auto ({auto,use}), mixed lists ("Поле","Auto") and explicit fields
# are objects / non-singleton lists and won't match → those keep object form.
function Is-AutoOnly($val) {
if ($null -eq $val) { return $false }
$arr = @($val)
if ($arr.Count -ne 1) { return $false }
return ($arr[0] -is [string]) -and ($arr[0] -eq 'Auto')
}
# Try to fold a structure tree into string shorthand "A > B > details".
# Conditions: linear chain (each level has exactly one child), each level is
# a plain group with single groupField and no local selection/order/filter.
# a plain group with single groupField and no local filter; selection/order are
# allowed only when they are the default single "Auto" element (see Is-AutoOnly).
function Try-StructureShorthand {
param($items)
if ($items.Count -ne 1) { return $null }
@@ -2506,8 +2519,8 @@ function Try-StructureShorthand {
# Disallow extras
if ($cur.Contains('type') -and $cur['type'] -ne 'group') { return $null }
if ($cur.Contains('name')) { return $null }
if ($cur.Contains('selection')) { return $null }
if ($cur.Contains('order')) { return $null }
if ($cur.Contains('selection') -and -not (Is-AutoOnly $cur['selection'])) { return $null }
if ($cur.Contains('order') -and -not (Is-AutoOnly $cur['order'])) { return $null }
if ($cur.Contains('filter')) { return $null }
if ($cur.Contains('viewMode')) { return $null }
if ($cur.Contains('itemsViewMode')) { return $null }
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# skd-decompile v0.89 — Decompile 1C DCS Template.xml to JSON DSL (draft)
# skd-decompile v0.90 — Decompile 1C DCS Template.xml to JSON DSL (draft)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
@@ -2600,6 +2600,17 @@ def build_structure(node, loc):
return items
def is_auto_only(val):
# True when selection/order is just the single auto element ("Auto") that the
# compiler adds by default to every shorthand group — folding such a group back
# to shorthand is bit-perfect (parse re-adds it on compile). Disabled auto
# ({auto,use}), mixed lists and explicit fields won't match → keep object form.
if val is None:
return False
arr = val if isinstance(val, list) else [val]
return len(arr) == 1 and isinstance(arr[0], str) and arr[0] == 'Auto'
def try_structure_shorthand(items):
if len(items) != 1:
return None
@@ -2610,9 +2621,9 @@ def try_structure_shorthand(items):
return None
if 'name' in cur:
return None
if 'selection' in cur:
if 'selection' in cur and not is_auto_only(cur['selection']):
return None
if 'order' in cur:
if 'order' in cur and not is_auto_only(cur['order']):
return None
if 'filter' in cur:
return None
+11 -1
View File
@@ -91,6 +91,13 @@ Shorthand: `"Имя [Заголовок]: тип = значение [availableVa
- `@autoDates` — генерирует пару скрытых параметров `ДатаНачала`/`ДатаОкончания` для StandardPeriod-параметра.
- `@hidden` — скрывает параметр от пользовательских настроек (для параметров-констант, используемых в запросе).
- `@always` — параметр всегда подставляется в запрос. Часто вместе с `@hidden`, но используется и отдельно (для видимых обязательных параметров типа отчётного периода).
- `@valueList` — разрешает передавать в параметр список значений (при значении-списке ниже подразумевается автоматически, отдельно указывать не обязательно).
Значение-список: несколько значений по умолчанию задаются через запятую в `значение`. Для запятой внутри одного значения — кавычки `'...'`.
```
"Виды [Виды субконто]: ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные = ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"
```
```
"ПС: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка @hidden"
@@ -115,6 +122,7 @@ Shorthand: `"ИмяПараметра [Заголовок] [ключ=значе
"ПериодОтчета [Отчетный период]" # только title
"ПорядокОкругления availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс."
"СчетПС value=ПланСчетов.Хозрасчетный.КассаПредприятия"
"Виды value=ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"
"Контрагент @hidden @always"
```
@@ -122,7 +130,7 @@ Shorthand: `"ИмяПараметра [Заголовок] [ключ=значе
`availableValue=` **заменяет весь список** допустимых значений (старые удаляются). Формат и кавычки — те же, что в `add-parameter`.
`value=` заменяет значение параметра (тип значения подбирается автоматически по объявленному типу параметра).
`value=` заменяет значение параметра. Несколько значений через запятую → **список значений** (заменяет все прежние); для запятой внутри значения — кавычки `'...'`.
Флаги `@hidden` / `@always` — те же, что и в `add-parameter`. Идемпотентны.
@@ -256,6 +264,8 @@ Value — имена ресурсов (как в полях/вычисляемы
Не поддерживает пакетный режим. Value — полный текст запроса или `@path/to/file.sql` (ссылка на внешний файл). Путь разрешается относительно Template.xml, затем CWD.
Когда что: **существенная переработка** (добавить поля, соединения, переписать пакет) → выгрузи запрос через `/skd-info <tpl> -Mode query -Name <набор> -Raw -OutFile file.sql`, отредактируй файл, верни `set-query @file`. `-Raw` отдаёт запрос целиком без декораций, поэтому выгрузка ↔ возврат точны (включая многопакетные запросы). **Точечная замена** (переименовать идентификатор, заменить подстроку) → выгрузка не нужна, используй `patch-query` ниже.
### patch-query — точечная замена в тексте запроса
Shorthand: `"старое => новое [@once]"`. По умолчанию заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
+140 -24
View File
@@ -1,4 +1,4 @@
# skd-edit v1.24 — Atomic 1C DCS editor
# skd-edit v1.25 — Atomic 1C DCS editor
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
@@ -378,7 +378,19 @@ function Parse-ParamShorthand {
$hasEq = $null -ne $Matches[3]
$rhs = $Matches[4]
if ($hasEq) {
$result.value = if ($rhs) { $rhs.Trim() } else { "" }
if ($rhs -and $rhs.Trim()) {
$items = Parse-ValueList $rhs.Trim()
if ($items.Count -ge 2) {
# Multi-value default → list; valueListAllowed implied
$result.value = $items
$result.valueListAllowed = $true
} else {
# Scalar (single item, quotes stripped) or empty sentinel
$result.value = if ($items.Count -eq 1) { $items[0] } else { "" }
}
} else {
$result.value = ""
}
}
} else {
$result.name = $s.Trim()
@@ -679,16 +691,13 @@ function Parse-OutputParamShorthand {
return @{ key = $s.Trim(); value = "" }
}
function Parse-AvailableValueList {
# Returns array of @{ value=...; presentation=... } from comma-separated list.
# Items can use 'single' or "double" quotes (stripped). Quoted spans preserve commas/colons.
function Split-QuotedCsv {
# Splits on top-level commas, respecting 'single' and "double" quoted spans.
# Returns raw (un-stripped, un-trimmed) item spans. Used by both availableValue
# (value:presentation) and value-list (values only) parsing.
param([string]$s)
$result = @()
if (-not $s) { return ,$result }
# Tokenize by ',' respecting quoted spans
$items = @()
if ($null -eq $s) { return ,$items }
$buf = New-Object System.Text.StringBuilder
$inQuote = $null
for ($i = 0; $i -lt $s.Length; $i++) {
@@ -707,16 +716,42 @@ function Parse-AvailableValueList {
}
}
if ($buf.Length -gt 0) { $items += $buf.ToString() }
return ,$items
}
# For each item: split into value[:presentation], strip quotes
$stripQuotes = {
param($t)
$t = $t.Trim()
if ($t.Length -ge 2 -and (($t[0] -eq "'" -and $t[-1] -eq "'") -or ($t[0] -eq '"' -and $t[-1] -eq '"'))) {
return $t.Substring(1, $t.Length - 2)
}
return $t
function Strip-Quotes {
# Strips a single surrounding pair of matching quotes; trims first.
param([string]$t)
$t = $t.Trim()
if ($t.Length -ge 2 -and (($t[0] -eq "'" -and $t[-1] -eq "'") -or ($t[0] -eq '"' -and $t[-1] -eq '"'))) {
return $t.Substring(1, $t.Length - 2)
}
return $t
}
function Parse-ValueList {
# Returns array of value strings (quotes stripped) split by top-level commas.
# No ':' handling — values may contain colons (e.g. dateTime 2024-01-01T12:30:00).
param([string]$s)
$result = @()
if ($null -eq $s) { return ,$result }
foreach ($raw in (Split-QuotedCsv $s)) {
$v = Strip-Quotes $raw
if ($v -ne "") { $result += $v }
}
return ,$result
}
function Parse-AvailableValueList {
# Returns array of @{ value=...; presentation=... } from comma-separated list.
# Items can use 'single' or "double" quotes (stripped). Quoted spans preserve commas/colons.
param([string]$s)
$result = @()
if (-not $s) { return ,$result }
$items = Split-QuotedCsv $s
$stripQuotes = { param($t) Strip-Quotes $t }
foreach ($raw in $items) {
$item = $raw.Trim()
@@ -1135,7 +1170,14 @@ function Build-ParamFragment {
}
$vla = [bool]$parsed.valueListAllowed
if ($null -ne $parsed.value) {
$valIsArray = ($parsed.value -is [array]) -or ($parsed.value -is [System.Collections.IList] -and $parsed.value -isnot [string])
if ($valIsArray) {
# Multi-value default (value-list): one <value> per item
foreach ($v in $parsed.value) {
$valueLines = Build-ParamValueXml -type $parsed.type -value $v -indent "$i`t"
foreach ($vl in $valueLines) { $lines += $vl }
}
} elseif ($null -ne $parsed.value) {
if (Test-EmptyValue $parsed.value) {
$emptyXml = Build-EmptyValueXml -type $parsed.type -indent "$i`t" -tagPrefix "" -tagName "value" -valueListAllowed $vla
if ($emptyXml) { $lines += $emptyXml }
@@ -2292,6 +2334,20 @@ switch ($Operation) {
$avPart = $rest.Substring($avIdx)
}
# Separate a multi-value value=... (list) — kv-regex below grabs only a single
# \S+ token, so a comma-separated list (with spaces) wouldn't be captured.
# availableValue already peeled, so 'value=' here is the real value key.
$valueListItems = $null
$vlIdx = $simpleRest.IndexOf('value=')
if ($vlIdx -ge 0) {
$vlRhs = $simpleRest.Substring($vlIdx + 'value='.Length)
$cand = Parse-ValueList $vlRhs
if ($cand.Count -ge 2) {
$valueListItems = $cand
$simpleRest = $simpleRest.Substring(0, $vlIdx).Trim()
}
}
# Process simple key=value pairs (use, denyIncompleteValues, value, etc.)
if ($simpleRest) {
$kvPairs = [regex]::Matches($simpleRest, '(\w+)=(\S+)')
@@ -2337,14 +2393,20 @@ switch ($Operation) {
$fragXml = $valueLines -join "`n"
}
$wasExisting = ($null -ne $existing)
if ($existing) {
# Capture position by next-element sibling, then remove existing
$refNode = $existing.NextSibling
# Collect ALL existing <value> (a param may carry a value-list) — scalar
# value= collapses them to one, so remove every <value>, not just the first.
$allValueEls = @()
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'value' -and $ch.NamespaceURI -eq $schNs) { $allValueEls += $ch }
}
$wasExisting = ($allValueEls.Count -gt 0)
if ($wasExisting) {
# Capture position after the last existing value, then remove all
$refNode = $allValueEls[$allValueEls.Count - 1].NextSibling
while ($refNode -and ($refNode.NodeType -eq 'Whitespace' -or $refNode.NodeType -eq 'SignificantWhitespace')) {
$refNode = $refNode.NextSibling
}
Remove-NodeWithWhitespace $existing
foreach ($ve in $allValueEls) { Remove-NodeWithWhitespace $ve }
} else {
# Insert before useRestriction/availableValue/denyIncompleteValues/use
$refNode = $null
@@ -2385,6 +2447,60 @@ switch ($Operation) {
}
}
# Process multi-value list (value=v1, v2, ...) — replace ALL <value>, ensure valueListAllowed=true
if ($valueListItems) {
# Declared type from <valueType>
$declaredType = ""
$vtEl = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'valueType' -and $ch.NamespaceURI -eq $schNs) { $vtEl = $ch; break }
}
if ($vtEl) {
foreach ($tnode in $vtEl.ChildNodes) {
if ($tnode.NodeType -eq 'Element' -and $tnode.LocalName -eq 'Type') {
$declaredType = $tnode.InnerText.Trim() -replace '^d\d+p\d+:', ''
break
}
}
}
# Remove ALL existing <value>; capture insertion ref after the last one
$valueEls = @()
foreach ($child in $paramEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq 'value' -and $child.NamespaceURI -eq $schNs) { $valueEls += $child }
}
$refNode = $null
if ($valueEls.Count -gt 0) {
$refNode = $valueEls[$valueEls.Count - 1].NextSibling
while ($refNode -and ($refNode.NodeType -eq 'Whitespace' -or $refNode.NodeType -eq 'SignificantWhitespace')) { $refNode = $refNode.NextSibling }
foreach ($ve in $valueEls) { Remove-NodeWithWhitespace $ve }
} else {
foreach ($child in $paramEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('useRestriction','availableValue','denyIncompleteValues','use')) { $refNode = $child; break }
}
}
foreach ($v in $valueListItems) {
$fragXml = (Build-ParamValueXml -type $declaredType -value $v -indent $childIndent) -join "`n"
$nodes = Import-Fragment $xmlDoc $fragXml
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent }
}
# Ensure <valueListAllowed>true</valueListAllowed> (schema order: after useRestriction, before availableValue/use)
$vlaEl = $null
foreach ($ch in $paramEl.ChildNodes) {
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'valueListAllowed' -and $ch.NamespaceURI -eq $schNs) { $vlaEl = $ch; break }
}
if ($vlaEl) {
if ($vlaEl.InnerText.Trim() -ne 'true') { $vlaEl.InnerText = 'true' }
} else {
$refVla = $null
foreach ($child in $paramEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('availableValue','denyIncompleteValues','use')) { $refVla = $child; break }
}
$nodes = Import-Fragment $xmlDoc "$childIndent<valueListAllowed>true</valueListAllowed>"
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refVla $childIndent }
}
$script:Dirty = $true; Write-Host "[OK] Parameter `"$paramName`": value set to list of $($valueListItems.Count) item(s)"
}
# Process availableValue — replace whole list with new items
if ($avPart) {
$avRest = ($avPart -replace '^availableValue=', '').Trim()
+107 -21
View File
@@ -1,4 +1,4 @@
# skd-edit v1.24 — Atomic 1C DCS editor (Python port)
# skd-edit v1.25 — Atomic 1C DCS editor (Python port)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
@@ -371,7 +371,18 @@ def parse_param_shorthand(s):
result["name"] = m.group(1).strip()
result["type"] = resolve_type_str(m.group(2).strip())
if m.group(3) is not None:
result["value"] = m.group(4).strip() if m.group(4) else ""
rhs = m.group(4)
if rhs and rhs.strip():
items = parse_value_list(rhs.strip())
if len(items) >= 2:
# Multi-value default → list; valueListAllowed implied
result["value"] = items
result["valueListAllowed"] = True
else:
# Scalar (single item, quotes stripped) or empty sentinel
result["value"] = items[0] if len(items) == 1 else ""
else:
result["value"] = ""
else:
result["name"] = s.strip()
@@ -631,14 +642,12 @@ def parse_output_param_shorthand(s):
return {"key": s.strip(), "value": ""}
def parse_available_value_list(s):
"""Returns list of {value, presentation} from comma-separated list.
Items can use single/double quotes (stripped). Quoted spans preserve commas/colons."""
if not s:
return []
# Tokenize by ',' respecting quoted spans
def split_quoted_csv(s):
"""Split on top-level commas, respecting single/double quoted spans.
Returns raw (un-stripped) item spans. Shared by availableValue and value-list parsing."""
items = []
if s is None:
return items
buf = []
in_quote = None
for ch in s:
@@ -656,12 +665,37 @@ def parse_available_value_list(s):
buf.append(ch)
if buf:
items.append("".join(buf))
return items
def strip_quotes(t):
t = t.strip()
if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')):
return t[1:-1]
return t
def strip_quotes(t):
"""Strip a single surrounding pair of matching quotes; trims first."""
t = t.strip()
if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')):
return t[1:-1]
return t
def parse_value_list(s):
"""Return list of value strings (quotes stripped) split by top-level commas.
No ':' handling values may contain colons (e.g. dateTime 2024-01-01T12:30:00)."""
if s is None:
return []
result = []
for raw in split_quoted_csv(s):
v = strip_quotes(raw)
if v != "":
result.append(v)
return result
def parse_available_value_list(s):
"""Returns list of {value, presentation} from comma-separated list.
Items can use single/double quotes (stripped). Quoted spans preserve commas/colons."""
if not s:
return []
items = split_quoted_csv(s)
result = []
for raw in items:
@@ -1012,7 +1046,12 @@ def build_param_fragment(parsed, indent):
lines.append(f"{i}\t</valueType>")
vla = bool(parsed.get("valueListAllowed"))
if parsed["value"] is not None:
if isinstance(parsed["value"], list):
# Multi-value default (value-list): one <value> per item
for v in parsed["value"]:
for vl in build_param_value_xml(parsed.get("type", ""), v, f"{i}\t"):
lines.append(vl)
elif parsed["value"] is not None:
if is_empty_value(parsed["value"]):
empty_xml = build_empty_value_xml(parsed.get("type", ""), f"{i}\t", "", "value", vla)
if empty_xml:
@@ -1987,6 +2026,17 @@ elif operation == "modify-parameter":
simple_rest = rest[:av_idx].strip()
av_part = rest[av_idx:]
# Separate a multi-value value=... (list) — kv-regex below grabs only a single
# \S+ token, so a comma-separated list (with spaces) wouldn't be captured.
value_list_items = None
vl_idx = simple_rest.find("value=")
if vl_idx >= 0:
vl_rhs = simple_rest[vl_idx + len("value="):]
cand = parse_value_list(vl_rhs)
if len(cand) >= 2:
value_list_items = cand
simple_rest = simple_rest[:vl_idx].strip()
# Process simple key=value pairs (use, denyIncompleteValues, etc.)
if simple_rest:
for m in re.finditer(r'(\w+)=(\S+)', simple_rest):
@@ -2012,12 +2062,15 @@ elif operation == "modify-parameter":
else:
value_lines = build_param_value_xml(declared_type, value, child_indent)
frag_xml = "\n".join(value_lines)
was_existing = existing is not None
if existing is not None:
# Find next-element sibling as ref before removing
idx = list(param_el).index(existing)
ref_node = param_el[idx + 1] if idx + 1 < len(param_el) else None
remove_node_with_whitespace(existing)
# Collect ALL existing <value> (a param may carry a value-list) — scalar
# value= collapses them to one, so remove every <value>, not just the first.
all_value_els = [ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "value" and etree.QName(ch.tag).namespace == SCH_NS]
was_existing = len(all_value_els) > 0
if was_existing:
last_idx = list(param_el).index(all_value_els[-1])
ref_node = param_el[last_idx + 1] if last_idx + 1 < len(param_el) else None
for ve in all_value_els:
remove_node_with_whitespace(ve)
else:
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("useRestriction", "availableValue", "denyIncompleteValues", "use")), None)
if frag_xml:
@@ -2040,6 +2093,39 @@ elif operation == "modify-parameter":
insert_before_element(param_el, node, ref_node, child_indent)
dirty = True; print(f'[OK] Parameter "{param_name}": {key}={value} added')
# Process multi-value list (value=v1, v2, ...) — replace ALL <value>, ensure valueListAllowed=true
if value_list_items:
declared_type = ""
vt_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS), None)
if vt_el is not None:
for tnode in vt_el:
if isinstance(tnode.tag, str) and local_name(tnode) == "Type":
declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip())
break
# Remove ALL existing <value>; capture insertion ref after the last one
value_els = [ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "value" and etree.QName(ch.tag).namespace == SCH_NS]
if value_els:
last_idx = list(param_el).index(value_els[-1])
ref_node = param_el[last_idx + 1] if last_idx + 1 < len(param_el) else None
for ve in value_els:
remove_node_with_whitespace(ve)
else:
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("useRestriction", "availableValue", "denyIncompleteValues", "use")), None)
for v in value_list_items:
frag_xml = "\n".join(build_param_value_xml(declared_type, v, child_indent))
for node in import_fragment(xml_doc, frag_xml):
insert_before_element(param_el, node, ref_node, child_indent)
# Ensure <valueListAllowed>true</valueListAllowed> (schema order: after useRestriction, before availableValue/use)
vla_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueListAllowed" and etree.QName(ch.tag).namespace == SCH_NS), None)
if vla_el is not None:
if (vla_el.text or "").strip() != "true":
vla_el.text = "true"
else:
ref_vla = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("availableValue", "denyIncompleteValues", "use")), None)
for node in import_fragment(xml_doc, f"{child_indent}<valueListAllowed>true</valueListAllowed>"):
insert_before_element(param_el, node, ref_vla, child_indent)
dirty = True; print(f'[OK] Parameter "{param_name}": value set to list of {len(value_list_items)} item(s)')
# Process availableValue
if av_part:
av_rest = av_part[len("availableValue="):].strip()
+9 -4
View File
@@ -1,7 +1,7 @@
---
name: skd-info
description: Анализ структуры схемы компоновки данных 1С (СКД) — наборы, поля, параметры, варианты. Используй для понимания отчёта — источник данных (запрос), доступные поля, параметры
argument-hint: <TemplatePath> [-Mode overview|query|fields|links|calculated|resources|params|variant|templates|trace|full] [-Name <dataset|variant|field|group>]
argument-hint: <TemplatePath> [-Mode overview|query|fields|links|calculated|resources|params|variant|templates|trace|full] [-Name <dataset|variant|field|group>] [-Raw]
allowed-tools:
- Bash
- Read
@@ -20,7 +20,8 @@ allowed-tools:
| `Mode` | Режим анализа (по умолчанию `overview`) |
| `Name` | Имя набора (query), поля (fields/calculated/resources/trace), варианта (variant) или группировки/поля (templates) |
| `Batch` | Номер пакета запроса, 0 = все (только query) |
| `Limit` / `Offset` | Пагинация (по умолчанию 150 строк) |
| `Raw` | (только query) сырой текст запроса целиком, без заголовков/оглавления/разделителей пакетов. Для выгрузки в `.sql` и возврата через `skd-edit set-query @file` |
| `Limit` / `Offset` | Пагинация (по умолчанию 150 строк; `-Raw` не усекается) |
| `OutFile` | Записать результат в файл (UTF-8 BOM) |
```powershell
@@ -31,6 +32,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-info.ps1" -Temp
```powershell
... -Mode query -Name НоменклатураСЦенами
... -Mode query -Name ДанныеТ13 -Batch 3
... -Mode query -Name ДанныеТ13 -Raw -OutFile query.sql
... -Mode fields -Name КадастроваяСтоимость
... -Mode calculated -Name КоэффициентКи
... -Mode resources -Name СуммаНалога
@@ -45,7 +47,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-info.ps1" -Temp
| Режим | Без `-Name` | С `-Name` |
|-------|-------------|-----------|
| `overview` | Навигационная карта схемы + подсказки Next | — |
| `query` | — | Текст запроса набора (с оглавлением батчей) |
| `query` | — | Текст запроса набора (с оглавлением батчей); `-Raw` — чистая выгрузка для правки |
| `fields` | Карта: имена полей по наборам | Деталь поля: набор, тип, роль, формат |
| `links` | Все связи наборов | — |
| `calculated` | Карта: имена вычисляемых полей | Выражение + заголовок + ограничения |
@@ -65,7 +67,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-info.ps1" -Temp
3. `query -Name <набор>` — посмотреть текст SQL-запроса
4. `variant -Name <N>` — посмотреть группировки и фильтры варианта
Подробные примеры вывода каждого режима — в `modes-reference.md`.
Переработка запроса (round-trip): `query -Name <набор> -Raw -OutFile q.sql`
правка `q.sql``/skd-edit <tpl> -Operation set-query -Value "@q.sql"`. Флаг
`-Raw` отдаёт запрос целиком без декораций, поэтому выгрузка ↔ возврат
точны (включая многопакетные запросы с временными таблицами).
## Верификация
-246
View File
@@ -1,246 +0,0 @@
# /skd-info — полная справка по режимам
Компактное описание — в [SKILL.md](SKILL.md).
## overview (по умолчанию) — карта схемы
Компактная навигационная карта (10-25 строк). Показывает структуру и подсказывает следующие шаги:
```
=== DCS: ОсновнаяСхемаКомпоновкиДанных (362 lines) ===
Sources: ИсточникДанных1 (Local)
Datasets:
[Query] НоменклатураСЦенами 7 fields, query 40 lines
Calculated: 1
Resources: 1
Templates: 1 templates, 1 group bindings
Params: (none)
Variants:
[1] НоменклатураИЦены "Номенклатура и цены" Table(detail) 3 filters
[2] НоменклатураБезЦен "Номенклатура без цен" Group(detail) 2 filters
Next:
-Mode query query text
-Mode fields field tables by dataset
-Mode calculated calculated field expressions
-Mode resources resource aggregation
-Mode variant -Name <N> variant structure (1..2)
```
Для DataSetUnion — дерево наборов + связи:
```
Datasets:
[Union] РасчетНалогаНаИмущество 52 fields
├─ [Query] РасчетНалогаНаИмущество 51 fields, query 181 lines
├─ [Query] ДанныеПоКадастровой 29 fields, query 40 lines
├─ [Query] ДанныеПоСреднегодовой 34 fields, query 41 lines
Links: РасчетНалогаНаИмущество -> СостояниеОС (2 fields)
```
Параметры разделяются на видимые/скрытые:
```
Params: 18 (7 visible, 11 hidden): Период, Ответственный, ...
```
## query — текст запроса
`-Name <набор>` — имя DataSet (обязателен если наборов > 1).
Извлекает raw-текст запроса с деэкранированием XML (`&amp;``&`, `&gt;``>`). Для пакетных запросов — оглавление батчей:
```
=== Query: ДанныеТ13 (334 lines, 13 batches) ===
Batch 1: lines 1-8 → ПОМЕСТИТЬ Представления_Периоды
Batch 2: lines 9-26 → ПОМЕСТИТЬ Представления_СотрудникиОрганизации
...
--- Batch 1 ---
ВЫБРАТЬ
ДАТАВРЕМЯ(1, 1, 1) КАК Период
ПОМЕСТИТЬ Представления_Периоды
...
```
Фильтр по номеру батча: `-Batch 3` покажет только 3-й пакет.
## fields — поля наборов данных
Без `-Name` — карта: имена полей по наборам:
```
=== Fields map ===
СостояниеОС [Query] (3): Организация, ОсновноеСредство, ДатаСостояния
РасчетНалогаНаИмущество [Union] (52): ДоляСтоимостиЧислитель, ...
РасчетНалогаНаИмущество [Query] (51): КадастроваяСтоимость, ...
```
С `-Name <поле>` — детали конкретного поля:
```
=== Field: ДатаСостояния "Дата ввода в эксплуатацию" ===
Dataset: СостояниеОС [Query]
Format: ДФ=dd.MM.yyyy
```
Показывает: dataset, title, type, role, useRestriction, format, presentationExpression.
## links — связи наборов данных
```
=== Links (4) ===
РасчетНалогаНаИмущество -> СостояниеОС :
Организация -> Организация
ОсновноеСредство -> ОсновноеСредство
```
Группирует по парам наборов. Показывает поля связи и параметры.
## calculated — вычисляемые поля
Без `-Name` — карта: имена и заголовки:
```
=== Calculated fields (23) ===
ДоляСтоимости "Доля стоимости"
КоэффициентКи "Коэффициент Ки"
...
```
С `-Name <поле>` — полное выражение:
```
=== Calculated: ДоляСтоимости ===
Expression:
ВЫБОР КОГДА ... ТОГДА "1" ИНАЧЕ ... КОНЕЦ
Title: Доля стоимости
Restrict: condition
```
## resources — ресурсы (итоги по группировкам)
Без `-Name` — карта: имена полей, `*` = есть формулы по группировкам:
```
=== Resources (51) ===
НалоговаяБаза
КоэффициентКи *
...
* = has group-level formulas
```
С `-Name <поле>` — формулы агрегации:
```
=== Resource: ДатаСостояния ===
[ОсновноеСредство] ЕстьNull(ДатаСостояния, "")
```
## params — параметры схемы
```
=== Parameters (16) ===
Name Type Default Visible Expression
Период StandardPeriod LastMonth yes -
НачалоПериода DateTime - hidden &Период.ДатаНачала
Организация CatalogRef.Организации null yes -
```
## variant — варианты отчёта
Без `-Name` — список вариантов:
```
=== Variants (2) ===
[1] НоменклатураИЦены "Номенклатура и цены" Table(detail) 3 filters
[2] НоменклатураБезЦен "Номенклатура без цен" Group(detail) 2 filters
```
С `-Name <N|имя>` — структура конкретного варианта:
```
=== Variant [1]: НоменклатураИЦены "Номенклатура и цены" ===
Structure:
Table "Таблица"
├── Columns: [ТипЦен Items]
│ Selection: Auto, Цена
└── Rows: [Номенклатура Items]
Selection: Номенклатура, УИД, Auto
Filter:
[ ] Номенклатура InHierarchy [user]
[ ] ТипЦен Equal
[x] ВАрхиве = false "Исключая скрытые товары"
DataParams: КлючВарианта="НоменклатураИЦены"
Output: style=ЧерноБелый groups=Separately totalsH=None totalsV=None
```
## templates — привязки шаблонов вывода
Три типа привязок: `fieldTemplate` (к полю), `groupTemplate` (к группировке, Header/Footer), `groupHeaderTemplate` (заголовок группы).
Без `-Name` — карта привязок:
```
=== Templates (70 defined: 49 field, 37 group) ===
Field bindings (49): (all trivial)
ОстаточнаяСтоимостьНа0101, ОстаточнаяСтоимостьНа0102, ...
Group bindings (37):
ВидНалоговойБазы
Header -> Макет3 (1 rows, 1 params)
СреднегодоваяСтоимость2019
Footer -> Макет50 (1 rows) spacer
GroupHeader -> Макет40 (3 rows)
```
С `-Name <группировка|поле>` — содержимое шаблонов:
```
=== Templates: СреднегодоваяСтоимость2019 ===
Footer -> Макет50 [1 rows, 1 cells]:
Row 1: (empty)
GroupHeader -> Макет40 [3 rows, 78 cells]:
Row 1: "№ п/п" | "###Группировки1###" | "Инв. номер" | ...
Row 2: "01.01" | "01.02" | ... | "31.12"
Row 3: "1" | "2" | ... | "26"
```
Для field-привязок:
```
=== Field template: ОстаточнаяСтоимостьНа0101 -> Макет4 ===
[1 rows, 1 cells]
Row 1: {ОстаточнаяСтоимостьНа0101}
(all params trivial)
```
**Тривиальность выражений**: `Поле = Поле` и `Поле = Представление(Поле)` считаются тривиальными и НЕ выводятся. Показываются только нетривиальные — когда выражение содержит другое поле, вызов метода, пустую строку и т.д.
## trace — трассировка поля от заголовка до запроса
Ищет поле по dataPath ИЛИ заголовку (включая подстроку) и показывает полную цепочку происхождения за один вызов:
```
=== Trace: КоэффициентКи "Коэффициент Ки" ===
Dataset: (schema-level only, not in dataset fields)
Calculated:
ВЫБОР КОГДА ... ТОГДА 0 ИНАЧЕ ... КОНЕЦ
Operands:
КоличествоМесяцевИспользования -> РасчетНалогаНаИмущество [Query]
КоличествоМесяцевВладения -> РасчетНалогаНаИмущество [Query]
Resource:
[ОсновноеСредство] Сумма(КоэффициентКи)
```
Типичный сценарий: пользователь видит колонку "Коэффициент Ки" в отчёте и спрашивает как она считается. Один вызов `trace` показывает: формулу вычисления, откуда берутся операнды, как агрегируется в ресурс.
## Что не выводится
- XML namespace-декларации
- Обёртки v8:item/v8:lang/v8:content (извлекаем чистый текст)
- userSettingID (GUID-ы пользовательских настроек)
- Дефолтные periodAdditionBegin/End = 0001-01-01
- viewMode
+11 -3
View File
@@ -1,4 +1,4 @@
# skd-info v1.5 — Analyze 1C DCS structure
# skd-info v1.6 — Analyze 1C DCS structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory=$true)]
@@ -10,7 +10,8 @@ param(
[int]$Batch = 0,
[int]$Limit = 150,
[int]$Offset = 0,
[string]$OutFile
[string]$OutFile,
[switch]$Raw
)
$ErrorActionPreference = "Stop"
@@ -655,6 +656,13 @@ function Show-Query {
}
$rawQuery = Unescape-Xml $queryNode.InnerText
# Raw mode: emit verbatim query text only (no headers/TOC/batch split) for round-trip
if ($Raw) {
foreach ($ql in ($rawQuery.Trim() -split "`n")) { $lines.Add($ql.TrimEnd()) }
return
}
$dsNameStr = $targetDs.SelectSingleNode("s:name", $ns).InnerText
# Split into batches
@@ -1894,7 +1902,7 @@ if ($Offset -gt 0) {
$result = $result[$Offset..($totalLines - 1)]
}
if ($result.Count -gt $Limit) {
if (-not $Raw -and $result.Count -gt $Limit) {
$shown = $result[0..($Limit - 1)]
foreach ($l in $shown) { Write-Host $l }
Write-Host ""
+10 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# skd-info v1.5 — Analyze 1C DCS structure
# skd-info v1.6 — Analyze 1C DCS structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
@@ -278,6 +278,7 @@ def main():
parser.add_argument("-Limit", type=int, default=150)
parser.add_argument("-Offset", type=int, default=0)
parser.add_argument("-OutFile", default=None)
parser.add_argument("-Raw", action="store_true")
args = parser.parse_args()
# --- Resolve path ---
@@ -634,6 +635,13 @@ def main():
sys.exit(1)
raw_query = unescape_xml("".join(query_node.itertext()))
# Raw mode: emit verbatim query text only (no headers/TOC/batch split) for round-trip
if args.Raw:
for ql in raw_query.strip().split("\n"):
lines.append(ql.rstrip())
return
ds_name_str = (target_ds.find("s:name", NSMAP).text or "")
# Split into batches
@@ -1719,7 +1727,7 @@ def main():
sys.exit(0)
result = result[args.Offset:]
if len(result) > args.Limit:
if not args.Raw and len(result) > args.Limit:
shown = result[:args.Limit]
for line in shown:
print(line)
+59 -21
View File
@@ -165,7 +165,7 @@ const form = await getFormState();
### Reading data
#### `readTable({ maxRows?, offset?, table? })``{ columns, rows, total, shown, offset }`
#### `readTable({ maxRows?, offset?, table? })``{ columns, rows, total, shown, offset, hasMore }`
Read actual grid data with pagination. Each row is `{ columnName: value }`.
| Option | Default | Description |
@@ -174,6 +174,12 @@ Read actual grid data with pagination. Each row is `{ columnName: value }`.
| `offset` | 0 | Skip first N rows |
| `table` | — | Grid name from `tables[]` (for multi-grid forms) |
**Picture columns.** Cells that render an icon (status/stage marks, the "ЭДО" mark, the attached-files paperclip) read as `'pic:<N>'` (`N` = icon frame/state) when shown, `''` when absent — so presence is truthy and icons differ by index. Icon-only columns (no header text) still appear, named by their tooltip or `'(picture)'`. These values are read-only — filter/select rows by a text column, not by `'pic:N'`.
```js
if (t.rows[0]['Присоединенные файлы']) { /* has an attached file */ }
t.rows[0]['ЭДО'] === 'pic:1'; // connected to 1С-ЭДО ('pic:0' = not)
```
Special row fields:
- `_kind: 'group'` — hierarchical group row
- `_kind: 'parent'` — parent row in hierarchy
@@ -183,10 +189,22 @@ Special row fields:
- `hierarchical: true` — list has groups (on result object)
- `viewMode: 'tree'` — tree view active (on result object)
**`total` is misleading for long lists.** 1С virtualizes both dynamic lists and form tabular sections — the DOM holds only a window of visible rows. `total` / `shown` count what's *loaded right now*, not the size of the underlying collection. Use **`hasMore`** to know if there's more data outside the window:
```js
const t = await readTable();
// t.hasMore = { above: false, below: true } ← form tabular section, scrollbar visible
// t.hasMore = { below: true } ← dynamic list (catalog/journal/register)
// t.hasMore = { below: false } ← everything visible / end of list reached
```
- `hasMore.below` — always present. `true` ⇒ scrolling down (PageDown / `clickElement` with `scroll:true`) will reveal more rows.
- `hasMore.above` — usually present too. Detected via the dynamic-list page-turn buttons (#vertButtonScroll) or the tabular-section scrollbar. Absent only for rare grids that have neither widget — treat absence as unknown.
```js
const t = await readTable({ maxRows: 50 });
console.log('Columns:', t.columns);
console.log('Rows:', t.rows.length, 'of', t.total);
console.log('Loaded:', t.shown, 'rows; more below:', t.hasMore.below);
// Pagination:
const page2 = await readTable({ maxRows: 50, offset: 50 });
```
@@ -217,7 +235,9 @@ Sections + all open tabs.
### Actions
#### `clickElement(text, { dblclick?, table?, expand?, modifier? })` → form state
**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `focused`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`.
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
- `table` — scope button search to a specific grid's command panel (by name from `tables[]`):
@@ -239,6 +259,11 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
await clickElement('ИСУ ФХД'); // select row
await clickElement('ИСУ ФХД', { expand: true }); // expand/collapse
```
- **Focus a field** (last resort, when no `table` given): if `text` matches no clickable control but matches a form field's name/label, clicks the input to focus it **without changing its value**. Returns `focused: { field, id, ok }` (`ok: false` if the field couldn't take focus). Use it to drive focus-dependent keys:
```js
await clickElement('Контрагент'); // focus the reference field
await getPage().keyboard.press('F4'); // open its selection form
```
- **Multi-select rows** with `modifier: 'ctrl'` (add to selection) or `modifier: 'shift'` (select range):
```js
await clickElement('Номенклатура 1'); // select first row
@@ -248,26 +273,32 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
const t = await readTable();
t.rows.filter(r => r._selected); // rows with _selected: true
```
- **SpreadsheetDocument cells** (report drill-down): first argument can be `{ row, column }` object to click a cell in a rendered report. Coordinates match `readSpreadsheet()` output:
- **Cell click by (row, column)** first argument as `{ row, column }`. Routes: spreadsheet on form → spreadsheet drill-down; otherwise → grid cell. Pass `table: 'GridName'` to force a specific grid when both are present.
Spreadsheet report drill-down:
```js
const report = await readSpreadsheet();
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000', ... }
// By data row index + column header name
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
// By cell value filter (fuzzy match)
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true });
// Totals row
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true });
await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); // by index
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); // by filter
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); // totals row
await clickElement('150 000', { dblclick: true }); // fallback: by text
```
Text search also works as fallback — searches inside spreadsheet iframes:
Form grid cell (catalog list, journal, table part). Off-viewport columns auto-scroll horizontally (works around frozen columns). Use `scroll: true | number` for filter-based rows outside the current DOM window:
```js
await clickElement('150 000', { dblclick: true }); // finds cell by text in report
await clickElement({ row: 0, column: 'Количество' }, { table: 'Товары', dblclick: true });
await clickElement({ row: { 'Номенклатура': 'Бумага' }, column: 'Цена' }, { table: 'Товары' });
await clickElement({ row: { 'Номер': '0000-000601' }, column: 'Сумма' },
{ table: 'Реализации', scroll: true }); // PageDown loop, max 50
```
#### `fillFields({ name: value })``{ filled, form }`
Gotchas:
- `row: <number>` is the index in the **current DOM window**, not absolute — 1С virtualizes long lists. `row: 0` is the topmost loaded row after any prior scroll. For arbitrary rows in a long list use `row: { col: val }` + `scroll: true`.
- `scroll: true` walks **down only** (PageDown). For going up first press `Home` via `getPage().keyboard` or narrow with `filterList`.
- First matching row wins on duplicate filter matches — refine the filter to disambiguate.
#### `fillFields({ name: value })` → form state with `filled`
Fill form fields by label (fuzzy match). Auto-detects field type.
| Value | Field type | Method |
@@ -286,8 +317,7 @@ await fillFields({
});
```
Returns `{ filled: [{ field, ok, value, method }], form: {...} }`.
Method is one of: `'clear'` | `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | `'typeahead'`
Returns form state with `filled: [{ field, ok: true, value, method }]` (method: `clear`|`toggle`|`radio`|`paste`|`dropdown`|`form`|`typeahead`). **Throws on any per-field failure** with a detailed message listing problematic fields and available options — if the call returned, all fields were filled, no per-item check needed.
#### `selectValue(field, search, opts?)` → form state with `selected`
Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. Pass empty `search` (`''` or `null`) to clear the field (Shift+F4).
@@ -310,14 +340,19 @@ await selectValue('Документ', '0000-000601', { type: 'Реализаци
Also supports DCS labels — auto-enables the paired checkbox.
#### `fillTableRow(fields, opts)` → form state
#### `fillTableRow(fields, opts)` → form state with `filled` (+ optional `notFilled`)
Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4).
Returns form state with `filled: [{ field, ok, ...}]`. Items are `{ field, ok: true, method, value }` on success (method: `direct`|`paste`|`dropdown`|`form`|`type-direct`|`skip`|`clear`|`toggle`) or `{ field, ok: false, error, message }` on per-field failure. Unmatched fields → `notFilled: [...]`.
**Unlike `fillFields`, `fillTableRow` does NOT throw on per-field failures** — errors appear as `ok: false` items in `filled[]` so the caller can react selectively (e.g. retry one cell while the rest of the row stays filled). Check via `r.filled.filter(f => !f.ok)`. Error codes: `composite_type`/`type_required`/`type_dialog_failed` (retry with `{value, type}`); `column_not_found` (check column name via `readTable`); `no_selection_form`/`no_selection_after_type` (retry or fall back to `selectValue`); `not_found`/`no_match`/`ambiguous` (refine search text); `still_open` (picked a group — pick a leaf row). Soft validation errors from 1C (`balloon`, `modal`) still throw via the exec-wrapper.
| Option | Description |
|--------|-------------|
| `tab` | Switch to tab before filling |
| `add` | Add new row before filling |
| `row` | Edit existing row by 0-based index |
| `row` | Edit existing row: 0-based index, **or** a `{ col: value }` filter (one or more columns) to locate the row by its cell values |
| `scroll` | With a `row` filter — scan beyond the current DOM window (`true` = up to 50 PageDowns, number = limit) |
| `table` | Grid name from `tables[]` (for multi-grid forms) |
```js
@@ -326,11 +361,14 @@ await fillTableRow(
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
{ tab: 'Товары', add: true }
);
// Edit existing row:
// Edit existing row by index:
await fillTableRow(
{ 'Количество': '20' },
{ tab: 'Товары', row: 0 }
);
// Edit existing row located by cell values (одна или несколько колонок):
await fillTableRow({ 'Цена': '120' }, { table: 'Товары', row: { 'Номенклатура': 'Бумага' } });
await fillTableRow({ 'Сумма': '500' }, { row: { 'Номер': '0000-000601', 'Дата': '29.12.2016' }, scroll: true });
// Multi-grid form — add row to specific table:
await fillTableRow(
{ 'Объект': 'БДДС' },
+6 -2
View File
@@ -5,9 +5,11 @@ Use this when the user asks to cover a 1C solution with automated regression tes
The runner is the same `run.mjs`. The mode is `test`:
```bash
node $RUN test [url] <dir|file> [flags]
node $RUN test <dir|file>... [flags]
```
Positional args are test paths (files and/or dirs, multiple allowed). URL is NOT positional — it comes from `webtest.config.mjs`; override with `--url=<url>`.
Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
## When to choose `test` over `exec`
@@ -362,14 +364,16 @@ Each `params` entry becomes its own test in the report. `{key}` placeholders in
node $RUN test tests/<app-name>/ # full app suite
node $RUN test tests/<app-name>/03-goods-receipt/ # one feature folder
node $RUN test tests/<app-name>/02-counterparties/01-create.test.mjs # one file
node $RUN test tests/<app-name>/02-x.test.mjs tests/<app-name>/05-y.test.mjs # several files
node $RUN test tests/<app-name>/ --tags=smoke # by tag (intersection)
node $RUN test tests/<app-name>/ --grep='накладн' # by name regex
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
node $RUN test tests/<app-name>/ --report=- # machine JSON to stdout, progress to stderr
node $RUN test tests/<app-name>/ -- --rebuild-stand # after `--` → hookArgs
```
Default report is JSON when `--report=` is given. Allure needs `--format=allure` + a directory. JUnit similarly with `--format=junit`.
**Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.)
### Allure static config — `_allure/`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
// web-test cli/commands/exec v1.0 — send script to running server
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import http from 'http';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { out, die, readStdin } from '../util.mjs';
import { loadSession } from '../session.mjs';
export async function cmdExec(fileOrDash, flags = {}) {
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|-> [--no-record]');
const code = fileOrDash === '-'
? await readStdin()
: readFileSync(resolve(fileOrDash), 'utf-8');
const sess = loadSession();
const headers = {};
if (flags.noRecord) headers['x-no-record'] = '1';
const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000;
const result = await new Promise((resolveP, reject) => {
const req = http.request({
hostname: '127.0.0.1', port: sess.port, path: '/exec',
method: 'POST', timeout: timeoutMs, headers,
}, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => { try { resolveP(JSON.parse(data)); } catch { reject(new Error(data)); } });
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); });
req.write(code);
req.end();
});
out(result);
if (!result.ok) process.exit(1);
}
@@ -0,0 +1,22 @@
// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { readFileSync } from 'fs';
import { resolve } from 'path';
import * as browser from '../../browser.mjs';
import { out, die, readStdin } from '../util.mjs';
import { executeScript } from '../exec-context.mjs';
export async function cmdRun(url, fileOrDash) {
if (!url || !fileOrDash) die('Usage: node src/run.mjs run <url> <file|->');
const code = fileOrDash === '-'
? await readStdin()
: readFileSync(resolve(fileOrDash), 'utf-8');
await browser.connect(url);
const result = await executeScript(code);
await browser.disconnect();
out(result);
if (!result.ok) process.exit(1);
}
@@ -0,0 +1,18 @@
// web-test cli/commands/shot v1.0 — take screenshot via server
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { writeFileSync } from 'fs';
import { out, die } from '../util.mjs';
import { loadSession } from '../session.mjs';
export async function cmdShot(file) {
const sess = loadSession();
const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`);
if (!resp.ok) {
const err = await resp.text();
die(`Screenshot failed: ${err}`);
}
const buf = Buffer.from(await resp.arrayBuffer());
const outFile = file || 'shot.png';
writeFileSync(outFile, buf);
out({ ok: true, file: outFile });
}
@@ -0,0 +1,33 @@
// web-test cli/commands/start v1.0
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import http from 'http';
import { writeFileSync } from 'fs';
import * as browser from '../../browser.mjs';
import { out, die } from '../util.mjs';
import { SESSION_FILE, cleanup } from '../session.mjs';
import { handleRequest } from '../server.mjs';
export async function cmdStart(url) {
if (!url) die('Usage: node src/run.mjs start <url>');
const state = await browser.connect(url);
const httpServer = http.createServer(handleRequest);
httpServer.listen(0, '127.0.0.1', () => {
const port = httpServer.address().port;
const session = {
port,
url,
pid: process.pid,
startedAt: new Date().toISOString()
};
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
out({ ok: true, message: 'Browser ready', port, ...state });
});
process.on('SIGINT', async () => {
await browser.disconnect();
cleanup();
process.exit(0);
});
}
@@ -0,0 +1,14 @@
// web-test cli/commands/status v1.0 — check session
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, readFileSync } from 'fs';
import { out } from '../util.mjs';
import { SESSION_FILE } from '../session.mjs';
export function cmdStatus() {
if (!existsSync(SESSION_FILE)) {
out({ ok: false, message: 'No active session' });
process.exit(1);
}
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
out({ ok: true, ...sess });
}
@@ -0,0 +1,17 @@
// web-test cli/commands/stop v1.0 — send stop to server
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { out } from '../util.mjs';
import { loadSession, cleanup } from '../session.mjs';
export async function cmdStop() {
const sess = loadSession();
try {
const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' });
const result = await resp.json();
out(result);
} catch {
// Server may have already exited before responding
out({ ok: true, message: 'Stopped' });
}
cleanup();
}
@@ -0,0 +1,458 @@
// web-test cli/commands/test v1.3 — regression test runner
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { resolve, dirname, basename, relative } from 'path';
import * as browser from '../../browser.mjs';
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs';
import { buildContext, buildScopedContext } from '../exec-context.mjs';
import { createAssertions } from '../test-runner/assertions.mjs';
import { buildSeverityIndex } from '../test-runner/severity.mjs';
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
import { discoverTests, resetState } from '../test-runner/discover.mjs';
export async function cmdTest(rawArgs) {
// Split off everything after `--` — those args belong to user-defined hooks
// (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs
// via the hookArgs field; the runner does not interpret them").
const sepIdx = rawArgs.indexOf('--');
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
// Parse flags
const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
let tags = null, grep = null, urlFlag = null;
const positional = [];
for (const a of ownArgs) {
if (a.startsWith('--tags=')) tags = a.slice(7).split(',');
else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i');
else if (a.startsWith('--url=')) urlFlag = a.slice(6);
else if (a === '--bail') opts.bail = true;
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
else if (a.startsWith('--report=')) opts.report = a.slice(9);
else if (a.startsWith('--format=')) opts.format = a.slice(9);
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13);
else if (a === '--record') opts.record = true;
else if (!a.startsWith('--')) positional.push(a);
}
// Positional args are ALWAYS test paths (one or many). URL comes from --url= or config
// (see webtest.config.mjs). This matches pytest/jest/playwright; a positional that looks
// like a URL is a mistake → fail fast with a hint instead of feeding it to page.goto().
const isUrl = (s) => /^https?:\/\//i.test(s);
let url = urlFlag || null;
const testPaths = [...positional];
if (testPaths.length === 0) {
die('Usage: node run.mjs test <dir|file>... [--url=URL] [--tags=...] [--grep=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
}
for (const p of testPaths) {
if (existsSync(resolve(p))) continue;
if (isUrl(p)) {
die(`"${p}" looks like a URL — use --url=<url>; positional args are test paths.`);
}
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
}
// Load config if exists. config (webtest.config.mjs) and hooks (_hooks.mjs) resolve from
// the FIRST path's directory — list paths from the same suite folder.
const firstPath = resolve(testPaths[0]);
const isFile = firstPath.endsWith('.test.mjs');
const testDir = isFile ? dirname(firstPath) : firstPath;
const configPath = resolve(testDir, 'webtest.config.mjs');
let config = {};
if (existsSync(configPath)) {
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
config = mod.default || {};
}
const severityIndex = buildSeverityIndex(config);
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
const contextSpecs = {};
let defaultContextName = 'default';
const defaultIsolation = config.isolation || 'tab';
if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) {
for (const [n, spec] of Object.entries(config.contexts)) {
contextSpecs[n] = { ...spec };
}
defaultContextName = config.defaultContext || Object.keys(config.contexts)[0];
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
} else {
const fallbackUrl = url || config.url;
if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
contextSpecs.default = { url: fallbackUrl };
}
if (!contextSpecs[defaultContextName]) {
die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
}
if (!url) url = contextSpecs[defaultContextName].url;
// Apply config defaults (CLI flags override)
if (!tags && config.tags) tags = config.tags;
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
browser.setPreserveClipboard(false);
}
opts.record = opts.record || !!config.record;
opts.screenshot = opts.screenshot || config.screenshot || 'on-failure';
if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`);
}
if (!['json', 'allure', 'junit'].includes(opts.format)) {
die(`Invalid --format=${opts.format} (expected json|allure|junit)`);
}
if (opts.format === 'junit' && !opts.report) {
die('--format=junit requires --report=path.xml');
}
// `--report=-` means "machine report to stdout" (Unix `-` convention).
// Only meaningful for streamable formats (json/junit); allure is a directory.
const reportToStdout = opts.report === '-';
if (reportToStdout && opts.format === 'allure') {
die('--report=- (stdout) is not valid with --format=allure: allure emits a directory of files, not a single stream. Use --report-dir=<dir> instead.');
}
const reportDir = opts.reportDir
? resolve(opts.reportDir)
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
if (opts.screenshot !== 'off') {
try { mkdirSync(reportDir, { recursive: true }); } catch {}
}
// Discover test files
const testFiles = discoverTests(testPaths);
if (!testFiles.length) die(`No *.test.mjs files found in ${testPaths.join(', ')}`);
// Import and filter tests
const tests = [];
let hasOnly = false;
for (const file of testFiles) {
const mod = await import('file:///' + file.replace(/\\/g, '/'));
const base = {
file: relative(testDir, file).replace(/\\/g, '/'),
name: mod.name || basename(file, '.test.mjs'),
tags: mod.tags || [],
timeout: mod.timeout || opts.timeout,
skip: mod.skip || false,
only: mod.only || false,
setup: mod.setup,
teardown: mod.teardown,
fn: mod.default,
param: undefined,
context: mod.context || null,
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
severity: typeof mod.severity === 'string' ? mod.severity : null,
};
if (base.only) hasOnly = true;
if (Array.isArray(mod.params) && mod.params.length) {
for (let i = 0; i < mod.params.length; i++) {
const p = mod.params[i];
const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`;
tests.push({ ...base, name, param: p });
}
} else {
tests.push(base);
}
}
// Filter
const filtered = tests.filter(t => {
if (hasOnly && !t.only) return false;
if (tags && !tags.some(tag => t.tags.includes(tag))) return false;
if (grep && !grep.test(t.name)) return false;
return true;
});
// Load hooks
const hooksPath = resolve(testDir, '_hooks.mjs');
let hooks = {};
if (existsSync(hooksPath)) {
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
}
// Human-readable report goes to stdout (test-runner convention: jest/pytest/playwright).
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
const W = reportToStdout ? process.stderr : process.stdout;
W.write(`\nweb-test -- ${url}\n`);
W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
const startedAt = new Date().toISOString();
const results = [];
let passCount = 0, failCount = 0, skipCount = 0;
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
const hookEnv = { hookArgs, log: hookLog, config };
if (hooks.prepare) await hooks.prepare(hookEnv);
// Lazy context creation
async function ensureContext(name) {
if (browser.hasContext(name)) return;
const spec = contextSpecs[name];
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
if (hooks.afterOpenContext && hookCtx) {
try { await hooks.afterOpenContext(hookCtx, name, spec); }
catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); }
}
}
let hookCtx = null;
function wrapCloseContextHook(target) {
const orig = target.closeContext;
if (typeof orig !== 'function') return;
target.closeContext = async (name) => {
if (hooks.beforeCloseContext) {
try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); }
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
}
return await orig(name);
};
}
try {
// Connect: create default context up front
await ensureContext(defaultContextName);
const ctx = buildContext({ noRecord: false });
ctx.assert = createAssertions();
ctx.log = (...a) => { /* per-test, overridden below */ };
wrapCloseContextHook(ctx);
hookCtx = ctx;
// Default context was created BEFORE hookCtx existed → fire afterOpenContext now.
if (hooks.afterOpenContext) {
try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); }
catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); }
}
if (hooks.beforeAll) await hooks.beforeAll(ctx);
let testIdx = 0;
for (const t of filtered) {
testIdx++;
const declaredContexts = t.contexts && t.contexts.length
? t.contexts
: [t.context || defaultContextName];
if (t.skip) {
const reason = typeof t.skip === 'string' ? t.skip : '';
W.write(`${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
skipCount++;
continue;
}
const testContextNames = declaredContexts;
try {
for (const cn of testContextNames) await ensureContext(cn);
await browser.setActiveContext(testContextNames[0]);
} catch (e) {
W.write(`${t.name} (context setup failed: ${e.message})\n`);
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
failCount++;
if (opts.bail) break;
continue;
}
let lastError = null;
let testResult = null;
const maxAttempts = 1 + opts.retry;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const output = [];
let steps = [];
let currentSteps = steps;
let stepIdx = 0;
const t0 = Date.now();
ctx.testInfo = {
name: t.name,
file: basename(t.file),
filePath: t.file,
tags: t.tags,
timeout: t.timeout,
attempt,
maxAttempts,
param: t.param,
contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])),
primaryContext: testContextNames[0],
};
ctx.testResult = null;
let videoFile = null;
if (opts.record) {
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; }
}
ctx.log = (...a) => output.push(a.map(String).join(' '));
ctx.step = async (name, fn) => {
const s = { name, start: Date.now(), status: 'passed', steps: [] };
currentSteps.push(s);
const prev = currentSteps;
currentSteps = s.steps;
stepIdx++;
const myIdx = stepIdx;
try {
await fn();
} catch (e) {
s.status = 'failed';
s.error = e.message;
throw e;
} finally {
s.stop = Date.now();
currentSteps = prev;
if (opts.screenshot === 'every-step' && s.status === 'passed') {
try {
const slug = slugify(name);
const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`);
const png = await browser.screenshot();
writeFileSync(file, png);
s.screenshot = file;
} catch {}
}
}
};
const scopedKeys = [];
if (t.contexts && t.contexts.length) {
for (const cn of t.contexts) {
ctx[cn] = buildScopedContext(cn);
wrapCloseContextHook(ctx[cn]);
scopedKeys.push(cn);
}
}
try {
if (hooks.beforeEach) await hooks.beforeEach(ctx);
if (t.setup) await t.setup(ctx);
let timeoutTimer;
try {
await Promise.race([
t.fn(ctx, t.param),
new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout); }),
]);
} finally {
// Clear the guard timer — otherwise it stays armed in the event loop and,
// since the success path never calls process.exit(), node can't exit until
// it fires (up to `timeout` ms after the last test finished).
clearTimeout(timeoutTimer);
}
if (t.teardown) try { await t.teardown(ctx); } catch {}
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
for (const cn of testContextNames) {
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
}
for (const k of scopedKeys) delete ctx[k];
if (videoFile) {
try { await browser.stopRecording(); } catch {}
}
const dur = elapsed(t0);
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
lastError = null;
break;
} catch (e) {
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
let shotFile = e.onecError?.screenshot;
if (!shotFile && opts.screenshot !== 'off') {
try {
const png = await browser.screenshot();
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
writeFileSync(shotFile, png);
} catch {}
}
if (t.teardown) try { await t.teardown(ctx); } catch {}
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError };
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
for (const cn of testContextNames) {
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
}
for (const k of scopedKeys) delete ctx[k];
if (videoFile) {
try { await browser.stopRecording(); } catch {}
}
lastError = errInfo;
const dur = elapsed(t0);
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
}
}
results.push(testResult);
if (testResult.status === 'passed') {
passCount++;
W.write(`${t.name} (${testResult.duration}s)\n`);
} else {
failCount++;
W.write(`${t.name} (${testResult.duration}s)\n`);
printSteps(W, testResult.steps, ' ');
if (lastError?.message) W.write(` ${lastError.message}\n`);
if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
}
if (opts.bail && testResult.status === 'failed') break;
}
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
} finally {
// Per-context teardown
try {
const remaining = browser.listContexts();
if (remaining.length > 0) {
const survivor = remaining[0];
try { await browser.setActiveContext(survivor); } catch {}
for (let i = remaining.length - 1; i >= 1; i--) {
const name = remaining[i];
if (hooks.beforeCloseContext && hookCtx) {
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
}
try { await browser.closeContext(name); }
catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
}
if (hooks.beforeCloseContext && hookCtx) {
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); }
}
}
} catch (e) {
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
}
try { await browser.disconnect(); } catch {}
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
}
const finishedAt = new Date().toISOString();
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
const report = {
runner: 'web-test', url, startedAt, finishedAt,
duration: totalDuration,
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
tests: results,
};
if (opts.format === 'allure') {
writeAllure(results, reportDir, severityIndex);
syncAllureExtras(testDir, reportDir);
} else if (opts.format === 'junit') {
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
} else if (reportToStdout) {
out(report);
} else if (opts.report) {
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
}
if (failCount > 0) process.exit(1);
}
@@ -0,0 +1,148 @@
// web-test cli/exec-context v1.0 — buildContext + executeScript для run/exec/test
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import * as browser from '../browser.mjs';
import { elapsed } from './util.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
/**
* Build a per-context wrapper: same shape as buildContext output, but every call
* is prefixed with `setActiveContext(name)` so the test can interleave actions
* across contexts (`ctx.a.click(...); ctx.b.click(...)`).
*/
export function buildScopedContext(name) {
const inner = buildContext({ noRecord: false });
const scoped = {};
for (const [k, v] of Object.entries(inner)) {
if (typeof v === 'function') {
scoped[k] = async (...args) => {
await browser.setActiveContext(name);
return v(...args);
};
} else {
scoped[k] = v;
}
}
return scoped;
}
export function buildContext({ noRecord = false } = {}) {
const ctx = {};
for (const [k, v] of Object.entries(browser)) {
if (k !== 'default') ctx[k] = v;
}
ctx.writeFileSync = writeFileSync;
ctx.readFileSync = readFileSync;
// --no-record: stub recording/narration functions to return safe defaults
if (noRecord) {
const noop = async () => {};
ctx.startRecording = noop;
ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
for (const fn of ['showCaption', 'hideCaption']) {
ctx[fn] = noop;
}
ctx.isRecording = () => false;
ctx.getCaptions = () => [];
}
// Wrap action functions to auto-detect 1C errors (modal, balloon)
// and stop execution immediately with diagnostic info
const ACTION_FNS = [
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
'closeForm', 'filterList', 'unfilterList'
];
for (const name of ACTION_FNS) {
if (typeof ctx[name] !== 'function') continue;
const orig = ctx[name];
ctx[name] = async (...args) => {
const result = await orig(...args);
const errors = result?.errors;
if (errors?.modal || errors?.balloon) {
// Screenshot while the error modal is still visible (before fetchErrorStack closes it)
let errorShot;
try {
const png = await ctx.screenshot();
errorShot = ERROR_SHOT_PATH;
writeFileSync(errorShot, png);
} catch {}
// Try to fetch call stack for modal errors before throwing
let stack = null;
if (errors?.modal && typeof ctx.fetchErrorStack === 'function') {
try {
stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
} catch { /* don't fail if stack fetch fails */ }
}
const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
const err = new Error(msg);
err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
throw err;
}
return result;
};
}
return ctx;
}
export async function executeScript(code, { noRecord } = {}) {
const output = [];
const origLog = console.log;
const origErr = console.error;
console.log = (...a) => output.push(a.map(String).join(' '));
console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' '));
const t0 = Date.now();
try {
const ctx = buildContext({ noRecord });
// Normalize Windows backslash paths to prevent JS parse errors
// (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence")
code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/'));
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const fn = new AsyncFunction(...Object.keys(ctx), code);
await fn(...Object.values(ctx));
console.log = origLog;
console.error = origErr;
return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) };
} catch (e) {
console.log = origLog;
console.error = origErr;
// Auto-stop recording if active (prevents "Already recording" on next exec)
if (browser.isRecording()) {
try { await browser.stopRecording(); } catch {}
}
// Error screenshot (skip if already taken before fetchErrorStack closed the modal)
let shotFile = e.onecError?.screenshot;
if (!shotFile) {
try {
const png = await browser.screenshot();
shotFile = ERROR_SHOT_PATH;
writeFileSync(shotFile, png);
} catch {}
}
const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) };
// Enrich with 1C error context if available
if (e.onecError) {
result.step = e.onecError.step;
result.stepArgs = e.onecError.args;
result.onecErrors = e.onecError.errors;
result.formState = e.onecError.formState;
if (e.onecError.stack) result.stack = e.onecError.stack;
}
return result;
}
}
@@ -0,0 +1,37 @@
// web-test cli/server v1.0 — HTTP server для exec/shot/stop/status в процессе start
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import * as browser from '../browser.mjs';
import { json, readBody } from './util.mjs';
import { cleanup } from './session.mjs';
import { executeScript } from './exec-context.mjs';
export async function handleRequest(req, res) {
try {
if (req.method === 'POST' && req.url === '/exec') {
const code = await readBody(req);
const noRecord = req.headers['x-no-record'] === '1';
const result = await executeScript(code, { noRecord });
json(res, result);
} else if (req.method === 'GET' && req.url === '/shot') {
const png = await browser.screenshot();
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(png);
} else if (req.method === 'POST' && req.url === '/stop') {
json(res, { ok: true, message: 'Stopping' });
await browser.disconnect();
cleanup();
process.exit(0);
} else if (req.method === 'GET' && req.url === '/status') {
json(res, { ok: true, connected: browser.isConnected() });
} else {
res.writeHead(404);
res.end('Not found');
}
} catch (e) {
json(res, { ok: false, error: e.message }, 500);
}
}
@@ -0,0 +1,20 @@
// web-test cli/session v1.0 — session-file helpers for HTTP-server mode
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, readFileSync, unlinkSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { die } from './util.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const SESSION_FILE = resolve(__dirname, '..', '..', '.browser-session.json');
export function loadSession() {
if (!existsSync(SESSION_FILE)) {
die('No active session. Run: node src/run.mjs start <url>');
}
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
}
export function cleanup() {
try { unlinkSync(SESSION_FILE); } catch {}
}
@@ -0,0 +1,64 @@
// web-test cli/test-runner/assertions v1.0 — ctx.assert API
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
export function createAssertions() {
class AssertionError extends Error {
constructor(msg, actual, expected) {
super(msg);
this.name = 'AssertionError';
this.actual = actual;
this.expected = expected;
}
}
return {
ok(value, msg) {
if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true);
},
equal(actual, expected, msg) {
if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected);
},
notEqual(actual, expected, msg) {
if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected);
},
deepEqual(actual, expected, msg) {
const a = JSON.stringify(actual), b = JSON.stringify(expected);
if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected);
},
includes(haystack, needle, msg) {
const h = Array.isArray(haystack) ? haystack : String(haystack);
if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle);
},
match(string, regex, msg) {
if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex);
},
async throws(fn, msg) {
try { await fn(); } catch { return; }
throw new AssertionError(msg || 'Expected function to throw');
},
// 1C-specific
formHasField(state, fieldName, msg) {
if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName);
},
formTitle(state, expected, msg) {
if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected);
},
tableHasRow(table, predicate, msg) {
const rows = table?.rows || [];
let found;
if (typeof predicate === 'function') {
found = rows.some(predicate);
} else {
found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v));
}
if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate);
},
tableRowCount(table, expected, msg) {
const actual = table?.rows?.length ?? 0;
if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected);
},
noErrors(state, msg) {
if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null);
},
};
}
@@ -0,0 +1,43 @@
// web-test cli/test-runner/discover v1.1 — test file discovery + state reset between tests
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, readdirSync } from 'fs';
import { resolve } from 'path';
// Accepts a single path or an array of paths (files and/or dirs). Each .test.mjs file is
// taken directly; each directory is walked recursively (skipping _ / . prefixes). Results
// are deduped and sorted — sorting preserves the numeric-prefix order the suite relies on
// (00-, 01-, …) even when paths are listed out of order.
export function discoverTests(testPaths) {
const paths = Array.isArray(testPaths) ? testPaths : [testPaths];
const files = [];
function walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
const full = resolve(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.test.mjs')) files.push(full);
}
}
for (const p of paths) {
const full = resolve(p);
if (full.endsWith('.test.mjs')) {
if (existsSync(full)) files.push(full);
} else if (existsSync(full)) {
walk(full);
}
}
return [...new Set(files)].sort();
}
export async function resetState(ctx) {
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
for (let i = 0; i < 10; i++) {
try {
const state = await ctx.getFormState();
// form === null means no form open (desktop). form === 0 is a real background form
// 1C exposes in some states — must still close it to fully reset.
if (state.form == null) break;
await ctx.closeForm({ save: false });
} catch { break; }
}
}
@@ -0,0 +1,113 @@
// web-test cli/test-runner/reporters v1.0 — Allure/JUnit writers + extras sync
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { writeFileSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs';
import { resolve, dirname, basename, relative } from 'path';
import { randomUUID } from 'crypto';
import { xmlEscape } from '../util.mjs';
import { resolveSeverity } from './severity.mjs';
/**
* Copy any files from `<testDir>/_allure/` into `reportDir`. Convention for
* Allure customization that doesn't fit inside per-test JSON:
* - `categories.json` failure classification (regex bucket)
* - `environment.properties` values shown in the Environment widget
* - `executor.json` CI/CD metadata
* Underscored folder mirrors `_hooks.mjs` convention (infra, not a test).
* Silent if folder absent.
*/
export function syncAllureExtras(testDir, reportDir) {
const extrasDir = resolve(testDir, '_allure');
if (!existsSync(extrasDir)) return;
try {
if (!statSync(extrasDir).isDirectory()) return;
} catch { return; }
for (const entry of readdirSync(extrasDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); }
catch { /* best-effort */ }
}
}
export function writeAllure(results, reportDir, severityIndex) {
for (const tr of results) {
if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
const uuid = randomUUID();
const suite = dirname(tr.file);
const suiteLabel = (suite && suite !== '.') ? suite : 'root';
const severity = resolveSeverity(tr, severityIndex);
const out = {
uuid,
name: tr.name,
fullName: tr.file,
status: tr.status,
stage: 'finished',
start: tr.start,
stop: tr.stop,
labels: [
...(tr.tags || []).map(t => ({ name: 'tag', value: t })),
{ name: 'suite', value: suiteLabel },
{ name: 'severity', value: severity },
],
steps: (tr.steps || []).map(allureStep),
attachments: [
...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []),
],
};
if (tr.status === 'failed' && tr.error) {
const traceParts = [];
if (tr.output) traceParts.push(tr.output);
const onecStack = tr.error.onecError?.stack?.raw;
if (onecStack) {
if (traceParts.length) traceParts.push('\n--- 1C stack ---\n');
traceParts.push(onecStack);
}
out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') };
}
writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2));
}
}
function allureStep(s) {
const out = {
name: s.name,
status: s.status,
stage: 'finished',
start: s.start,
stop: s.stop,
steps: (s.steps || []).map(allureStep),
};
if (s.screenshot) {
out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }];
}
if (s.status === 'failed' && s.error) {
out.statusDetails = { message: s.error, trace: s.error };
}
return out;
}
export function buildJUnit(report, testDir) {
const { summary, duration, tests } = report;
const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.';
const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
lines.push(`<testsuites name="web-test" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
lines.push(` <testsuite name="${xmlEscape(suiteName)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
for (const t of tests) {
const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`;
if (t.status === 'passed') {
lines.push(` <testcase ${attrs}/>`);
} else if (t.status === 'skipped') {
lines.push(` <testcase ${attrs}><skipped/></testcase>`);
} else {
lines.push(` <testcase ${attrs}>`);
const msg = t.error?.message || '';
const trace = t.output || '';
lines.push(` <failure message="${xmlEscape(msg)}">${xmlEscape(trace)}</failure>`);
if (t.screenshot) lines.push(` <system-out>screenshot: ${xmlEscape(t.screenshot)}</system-out>`);
lines.push(` </testcase>`);
}
}
lines.push(` </testsuite>`);
lines.push(`</testsuites>`);
return lines.join('\n');
}
@@ -0,0 +1,66 @@
// web-test cli/test-runner/severity v1.0 — Allure severity policy resolver
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { die } from '../util.mjs';
export const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 };
export const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK);
/**
* Validate config.severity (inverted map: severity [tags]) at config load time.
* Returns:
* - tagToSeverity: Map<tag, severity> (precomputed lookup for the resolver)
* - defaultSeverity: string (validated, defaults to 'normal')
* Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets.
*/
export function buildSeverityIndex(config) {
const tagToSeverity = new Map();
const sev = config.severity || {};
if (typeof sev !== 'object' || Array.isArray(sev)) {
die(`config.severity must be an object, got ${typeof sev}`);
}
for (const [level, tags] of Object.entries(sev)) {
if (!SEVERITY_LEVELS.includes(level)) {
die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`);
}
if (!Array.isArray(tags)) {
die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`);
}
for (const tag of tags) {
if (tagToSeverity.has(tag)) {
die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`);
}
tagToSeverity.set(tag, level);
}
}
const def = config.defaultSeverity || 'normal';
if (!SEVERITY_LEVELS.includes(def)) {
die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`);
}
return { tagToSeverity, defaultSeverity: def };
}
/**
* Resolve a test's severity. Precedence:
* 1. explicit `export const severity` from the test module
* 2. max-rank severity found among tags (either standard severity name, or mapped via config)
* 3. defaultSeverity from config (or 'normal' if not set)
* Returns one of SEVERITY_LEVELS.
*/
export function resolveSeverity(t, severityIndex) {
if (t.severity) {
if (!SEVERITY_LEVELS.includes(t.severity)) {
return severityIndex.defaultSeverity;
}
return t.severity;
}
let best = null;
for (const tag of t.tags || []) {
let candidate = null;
if (SEVERITY_LEVELS.includes(tag)) candidate = tag;
else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag);
if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) {
best = candidate;
}
}
return best || severityIndex.defaultSeverity;
}
@@ -0,0 +1,113 @@
// web-test cli/util v1.2 — generic helpers for CLI commands
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
export function out(obj) {
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
}
export function die(msg) {
process.stderr.write(msg + '\n');
process.exit(1);
}
export function json(res, obj, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj, null, 2));
}
export async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf-8');
}
export async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf-8');
}
export function elapsed(t0) {
return Math.round((Date.now() - t0) / 100) / 10;
}
export function elapsed2(start, stop) {
return Math.round(((stop || Date.now()) - start) / 100) / 10;
}
export function slugify(s) {
return String(s).trim()
.replace(/[\s/\\:*?"<>|]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'step';
}
export function formatDuration(seconds) {
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
const m = Math.floor(seconds / 60);
const s = Math.round((seconds - m * 60) * 10) / 10;
return `${m}m ${s}s`;
}
export function xmlEscape(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
export function interpolate(template, params) {
return String(template).replace(/\{(\w+)\}/g, (_, key) =>
params[key] !== undefined ? String(params[key]) : `{${key}}`);
}
export function printSteps(W, steps, indent) {
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const last = i === steps.length - 1;
const prefix = last ? '└' : '├';
const mark = s.status === 'failed' ? '✗ ' : '';
W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`);
if (s.error && s.status === 'failed') {
W.write(`${indent} ${s.error}\n`);
}
if (s.steps.length) printSteps(W, s.steps, indent + ' ');
}
}
export function usage() {
die(`Usage: node run.mjs <command> [args]
Commands:
start <url> Launch browser and connect to 1C web client
run <url> <file|-> Autonomous: connect, execute script, disconnect
exec <file|-> [options] Execute script (file path or - for stdin)
shot [file] Take screenshot (default: shot.png)
stop Logout and close browser
status Check session status
test <dir|file>... Run regression tests (*.test.mjs); accepts multiple paths
Options for exec:
--no-record Skip video recording (record() becomes no-op)
Global options (any command):
--no-preserve-clipboard Don't save/restore OS clipboard around action calls.
Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally).
Options for test:
--url=URL Override the base URL (default: from webtest.config.mjs)
--tags=smoke,crud Filter tests by tags
--grep=pattern Filter tests by name (regex)
--bail Stop on first failure
--retry=N Retry failed tests N times
--timeout=ms Per-test timeout (default: 30000)
--report=path Write machine report (JSON/JUnit) to file
--report=- Write machine report to stdout (progress moves to stderr)
--report-dir=path Directory for screenshots and other artifacts
--screenshot=mode on-failure (default) | every-step | off
--format=fmt json (default) | allure | junit
--record Record video for each test (mp4 in report-dir)
-- <hook-args...> Everything after \`--\` is forwarded to _hooks.mjs
prepare/cleanup as hookArgs (runner does not parse it).
Example: ... tests/web-test/ -- --rebuild-stand`);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
// web-test dom shared v1.0 — embedded JS function constants
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Shared function strings embedded into page.evaluate() generators.
* Не экспортируются наружу через dom.mjs facade внутренняя кухня.
*/
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
const all = document.querySelectorAll('#modalSurface');
for (const el of all) { if (el.offsetWidth > 0) return true; }
return false;
}`;
/** Detect active form number. Picks form with most visible elements, skipping form0.
* When modalSurface is visible prefer the highest-numbered form (modal dialog). */
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
function detectForm() {
const counts = {};
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
if (el.offsetWidth === 0) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
});
const nums = Object.keys(counts).map(Number);
if (!nums.length) return null;
const candidates = nums.filter(n => n > 0);
if (!candidates.length) return nums[0];
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
if (hasVisibleModal()) {
const maxForm = Math.max(...candidates);
if (counts[maxForm] >= 1) return maxForm;
}
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
}`;
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
* Works even when the open-windows tab bar is hidden. */
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
function detectForms() {
const counts = {};
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
if (el.offsetWidth === 0) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
});
const nums = Object.keys(counts).map(Number);
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
}`;
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
export const READ_FORM_FN = `function readForm(p) {
const result = {};
const fields = [];
const buttons = [];
const formTabs = [];
const texts = [];
const hyperlinks = [];
// Normalize non-breaking spaces to regular spaces
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
// Fields (inputs)
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
const actions = [];
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
const field = { name, value: el.value || '' };
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
if (!field.value) {
const labelEl = document.getElementById(p + name);
if (labelEl) {
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
.map(c => nbsp(c.innerText?.trim() || ''))
.filter(Boolean);
if (chipTexts.length) field.value = chipTexts.join(', ');
}
}
if (label && label !== name) field.label = label;
if (el.readOnly) field.readonly = true;
if (el.disabled) field.disabled = true;
if (el.type && el.type !== 'text') field.type = el.type;
if (document.activeElement === el) field.focused = true;
if (actions.length) field.actions = actions;
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
fields.push(field);
});
// Textareas
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
const field = { name, value: el.value || '', type: 'textarea' };
if (label && label !== name) field.label = label;
if (el.readOnly) field.readonly = true;
if (el.disabled) field.disabled = true;
if (document.activeElement === el) field.focused = true;
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
fields.push(field);
});
// Checkboxes
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = nbsp(titleEl?.innerText?.trim() || '');
const field = {
name,
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
type: 'checkbox'
};
if (label && label !== name) field.label = label;
fields.push(field);
});
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
const radioGroups = {};
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
if (el.offsetWidth === 0) return;
const id = el.id.replace(p, '');
const m = id.match(/^(.+?)#(\\d+)#radio$/);
if (m) {
// Options 1, 2, ... have explicit #N#radio suffix
const [, groupName, idx] = m;
if (!radioGroups[groupName]) radioGroups[groupName] = [];
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
} else if (!id.includes('#')) {
// Base element = option 0 (no #0#radio suffix)
if (!radioGroups[id]) radioGroups[id] = [];
const labelEl = document.getElementById(p + id + '#0#radio_text');
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
}
});
for (const [name, options] of Object.entries(radioGroups)) {
const titleEl = document.getElementById(p + name + '#title_text');
const label = titleEl?.innerText?.trim() || '';
const selected = options.find(o => o.selected);
const field = {
name,
value: selected?.label || '',
type: 'radio',
options: options.map(o => o.label)
};
if (label && label !== name) field.label = label;
fields.push(field);
}
// Buttons (a.press)
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const idName = el.id.replace(p, '');
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
const span = el.querySelector('.submenuText') || el.querySelector('span');
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
if (!text && !el.classList.contains('pressCommand')) return;
const btn = { name: text || idName };
if (el.classList.contains('pressDefault')) btn.default = true;
if (el.classList.contains('pressDisabled')) btn.disabled = true;
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
if (!text) {
const tip = nbsp(el.title || el.parentElement?.title || '');
if (tip) btn.tooltip = tip;
}
buttons.push(btn);
});
// Frame buttons
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
if (el.offsetWidth === 0) return;
const text = nbsp(el.innerText?.trim() || '');
const idName = el.id?.replace(p, '') || '';
if (!text && !idName) return;
buttons.push({ name: text || idName, frame: true });
});
// Tumbler items
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
if (el.offsetWidth === 0) return;
const text = el.innerText?.trim();
const idName = el.id?.replace(p, '') || '';
buttons.push({ name: text || idName, tumbler: true });
});
// Tabs — scoped to form by checking ancestor IDs
document.querySelectorAll('[data-content]').forEach(el => {
if (el.offsetWidth === 0) return;
let node = el.parentElement;
let inForm = false;
while (node) {
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
node = node.parentElement;
}
if (!inForm) return;
const tab = { name: el.dataset.content };
if (el.classList.contains('select')) tab.active = true;
formTabs.push(tab);
});
// Static texts and hyperlinks
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
if (name.endsWith('_div') || name.includes('#title')) return;
const text = el.innerText?.trim();
if (!text) return;
if (el.classList.contains('staticTextHyper')) {
hyperlinks.push({ name: text });
} else {
const titleEl = document.getElementById(p + name + '#title_text');
const label = titleEl?.innerText?.trim() || '';
const entry = { name, value: text };
if (label) entry.label = label;
texts.push(entry);
}
});
// Tables/grids — collect ALL visible grids
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (allGrids.length > 0) {
const tables = allGrids.map(grid => {
const name = grid.id ? grid.id.replace(p, '') : '';
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) {
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height });
} else {
// Unnamed column — check if data cells contain checkboxes
const firstLine = body?.querySelector('.gridLine');
if (firstLine) {
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
if (cells[idx]?.querySelector('.checkbox')) {
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
}
}
}
});
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
const firstLine = body?.querySelector('.gridLine');
if (firstLine && columns.length > 0) {
const xGrp = new Map();
columns.forEach(c => {
const k = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGrp.has(k)) xGrp.set(k, []);
xGrp.get(k).push(c);
});
for (const [k, hdrs] of xGrp) {
if (hdrs.length !== 1) continue;
let cnt = 0;
[...firstLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
});
if (cnt > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
for (let si = 0; si < cnt; si++) {
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
}
}
}
}
}
const colNames = columns.map(c => c.text);
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
const titleEl = document.getElementById(p + name + '#title_div')
|| document.getElementById(p + 'Группа' + name + '#title_div');
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
});
result.tables = tables;
// Backward compat: table = first grid summary
const first = tables[0];
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
}
// Active filters (train badges above grid: *СостояниеПросмотра)
const filters = [];
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
if (el.offsetWidth === 0) return;
const titleEl = el.querySelector('.trainName');
const valueEl = el.querySelector('.trainTitle');
if (!titleEl && !valueEl) return;
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
if (field || value) filters.push({ field, value });
});
// Also check search field value
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
if (searchInput?.value) {
filters.push({ type: 'search', value: searchInput.value });
}
if (filters.length) result.filters = filters;
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
const navigation = [];
const formEl = document.querySelector('[id^="' + p + '"]');
if (formEl) {
let pageEl = formEl.parentElement;
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
if (pageEl) {
pageEl.querySelectorAll('.navigationItem').forEach(el => {
if (el.offsetWidth === 0) return;
const nameEl = el.querySelector('.navigationItemName');
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
if (!text) return;
const nav = { name: text };
if (el.classList.contains('select')) nav.active = true;
navigation.push(nav);
});
}
}
// Iframes
let iframeCount = 0;
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
});
if (iframeCount) result.iframes = iframeCount;
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;
// Group DCS report settings into readable format
if (result.fields) {
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
const dcsGroups = {};
const dcsNames = new Set();
for (const f of result.fields) {
const m = f.name.match(dcsRe);
if (!m) continue;
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
dcsGroups[m[1]][m[3]] = f;
dcsNames.add(f.name);
}
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
if (dcsEntries.length) {
result.reportSettings = dcsEntries.map(([, g]) => {
const cb = g['Использование'];
const val = g['Значение'];
if (!cb && !val) return null;
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
const s = { name: label, enabled: cb ? !!cb.value : true };
if (val) {
s.value = val.value || '';
if (val.actions && val.actions.length) s.actions = val.actions;
}
return s;
}).filter(Boolean);
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
if (!result.fields.length) delete result.fields;
}
}
return result;
}`;
+108
View File
@@ -0,0 +1,108 @@
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Read the `#editDropDown` autocomplete popup.
*
* Returns `{ visible: false }` when EDD is absent/hidden, or
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
* for `page.mouse.click(x, y)`.
*
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
* from DLB prefer `clickEddItemViaDispatchScript` for those cases.
*/
export function readEddScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return { visible: false };
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
return {
visible: true,
items: eddTexts.map(el => {
const r = el.getBoundingClientRect();
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
})
};
})()`;
}
/**
* Is the EDD popup currently visible? Returns boolean.
* Lighter than `readEddScript` when only presence matters.
*/
export function isEddVisibleScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
return !!(edd && edd.offsetWidth > 0);
})()`;
}
/**
* Click an EDD item by name via `dispatchEvent` bypasses `div.surface`
* overlays from DLB that intercept `page.mouse.click`.
*
* Matching is fuzzy: exact (with optional `(suffix)` strip) includes,
* normalizes ё/е and NBSP.
*
* Returns the clicked item's innerText (trimmed), or `null` when no match.
*/
export function clickEddItemViaDispatchScript(itemName) {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return null;
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(itemName.toLowerCase())});
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
function clickEl(el) {
const r = el.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.dispatchEvent(new MouseEvent('click', opts));
return el.innerText.trim();
}
// Pass 1: exact match (prefer over partial)
for (const el of items) {
const t = ny((el.innerText?.trim() || '').toLowerCase());
if (t === target) return clickEl(el);
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
if (stripped === target) return clickEl(el);
}
// Pass 2: partial match
for (const el of items) {
const t = ny((el.innerText?.trim() || '').toLowerCase());
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
}
return null;
})()`;
}
/**
* Click the "Показать все" / "Show all" link in the EDD footer via
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
* to scanning for span/div/a with the literal text.
*
* Returns boolean whether the link was found and clicked.
*/
export function clickShowAllInEddScript() {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return false;
let el = edd.querySelector('.eddBottom .hyperlink');
if (!el || el.offsetWidth === 0) {
const candidates = [...edd.querySelectorAll('span, div, a')]
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
el = candidates.find(e => {
const t = (e.innerText?.trim() || '').toLowerCase();
return t === 'показать все' || t === 'show all';
});
}
if (!el) return false;
const r = el.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
el.dispatchEvent(new MouseEvent('mousedown', opts));
el.dispatchEvent(new MouseEvent('mouseup', opts));
el.dispatchEvent(new MouseEvent('click', opts));
return true;
})()`;
}
@@ -0,0 +1,63 @@
// web-test dom/edit-state v1.1 — focus and popup detection inside the 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
* Returns boolean.
*
* @param {object} [opts]
* @param {boolean} [opts.allowTextarea=false] also return true for TEXTAREA.
*/
export function isInputFocusedScript({ allowTextarea = false } = {}) {
const cond = allowTextarea
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
: `f.tagName === 'INPUT'`;
return `(() => {
const f = document.activeElement;
return !!(f && (${cond}));
})()`;
}
/**
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
* Used to verify grid edit-mode (active cell editor).
*
* @param {string} [gridSelector] when given, only `true` if the focused input
* is inside that specific grid. Without it any `.grid` ancestor counts.
*
* Returns boolean.
*/
export function isInputFocusedInGridScript(gridSelector) {
const sel = gridSelector ? JSON.stringify(gridSelector) : 'null';
return `(() => {
const f = document.activeElement;
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
const sel = ${sel};
if (sel) {
const grid = document.querySelector(sel);
return !!(grid && grid.contains(f));
}
let n = f;
while (n) {
if (n.classList?.contains('grid')) return true;
n = n.parentElement;
}
return false;
})()`;
}
/**
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
* Returns `'calculator' | 'calendar' | null`.
*
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
*/
export function findOpenPopupScript() {
return `(() => {
const calc = document.querySelector('.calculate');
if (calc && calc.offsetWidth > 0) return 'calculator';
const cal = document.querySelector('.frameCalendar');
if (cal && cal.offsetWidth > 0) return 'calendar';
return null;
})()`;
}
@@ -0,0 +1,65 @@
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
// open detailed error dialog, read textarea, close cleanup dialogs.
/** Find OpenReport link coordinates on the error modal for given formNum. */
export function getOpenReportCoordsScript(formNum) {
return `(() => {
const el = document.getElementById('form${formNum}_OpenReport#text');
if (!el || el.offsetWidth <= 2) return null;
const rect = el.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
})()`;
}
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
export function isErrorDetailLinkVisibleScript() {
return `(() => {
const links = document.querySelectorAll('a, [class*="hyper"], span');
for (const el of links) {
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
}
return false;
})()`;
}
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
export function readLargestVisibleTextareaScript() {
return `(() => {
let best = null;
document.querySelectorAll('textarea').forEach(ta => {
if (ta.offsetWidth > 0 && ta.value.length > 0) {
if (!best || ta.value.length > best.value.length) best = ta;
}
});
return best?.value || null;
})()`;
}
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
export function clickTopCloudOkButtonScript() {
return `(() => {
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
.filter(w => w.offsetWidth > 0)
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
for (const w of psWins) {
const ok = w.querySelector('button.webBtn, .pressDefault');
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
}
return false;
})()`;
}
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
export function clickReportCloseButtonScript() {
return `(() => {
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
.filter(w => w.offsetWidth > 0);
for (const w of psWins) {
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
}
})()`;
}
@@ -0,0 +1,127 @@
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Check for validation errors / diagnostics after an action.
* Detects three patterns:
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
* Returns { balloon, messages[], modal } or null if no errors.
*/
export function checkErrorsScript() {
return `(() => {
const result = {};
// 1. Inline balloon tooltip
const balloon = document.querySelector('.balloon');
if (balloon && balloon.offsetWidth > 0) {
const msg = balloon.querySelector('.balloonMessage');
const title = balloon.querySelector('.balloonTitle');
if (msg) {
result.balloon = {
title: title?.innerText?.trim() || 'Ошибка',
message: msg.innerText?.trim() || ''
};
// Count navigation arrows to indicate total errors
const fwd = balloon.querySelector('.balloonJumpFwd');
const back = balloon.querySelector('.balloonJumpBack');
const fwdDisabled = fwd?.classList.contains('disabled');
const backDisabled = back?.classList.contains('disabled');
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
if (back && !backDisabled) result.balloon.hasPrev = true;
}
}
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
for (const msgPanel of msgPanels) {
const msgs = [];
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
if (line.offsetWidth === 0) return;
const textEl = line.querySelector('.gridBoxText');
const text = (textEl || line).innerText?.trim();
if (text) msgs.push(text);
});
if (msgs.length > 0) { result.messages = msgs; break; }
}
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
// so we always scan for small forms with button patterns, regardless of modalSurface state
const formButtons = {};
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
if (btn.offsetWidth === 0) return;
const container = btn.closest('[id$="_container"]');
const m = container?.id?.match(/^form(\\d+)_/);
if (!m) return;
const fn = m[1];
if (!formButtons[fn]) formButtons[fn] = [];
formButtons[fn].push(btn);
});
for (const [fn, buttons] of Object.entries(formButtons)) {
const p = 'form' + fn + '_';
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
if (elCount > 100) continue; // Skip large content forms
if (buttons.length > 1) {
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
// Must have a Message element — real 1C confirmations always have form{N}_Message.
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
const msgEl = document.getElementById(p + 'Message');
if (!msgEl || msgEl.offsetWidth === 0) continue;
const message = msgEl.innerText?.trim() || '';
const btnNames = buttons.map(el => {
const b = { name: el.innerText?.trim() || '' };
if (el.classList.contains('pressDefault')) b.default = true;
return b;
}).filter(b => b.name);
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
break;
}
}
// Single-button modal: error dialog with pressDefault + staticText
// Skip forms with input fields — those are data entry forms (e.g. register record),
// not error dialogs. Real error modals only have staticText + buttons.
if (!result.confirmation) {
for (const [fn, buttons] of Object.entries(formButtons)) {
const p = 'form' + fn + '_';
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
if (elCount > 100) continue;
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
if (hasInputs) continue;
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
.filter(el => el.offsetWidth > 0)
.map(el => el.innerText?.trim())
.filter(Boolean);
if (texts.length > 0) {
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
// Check if OpenReport link is available (platform exceptions have visible link text)
const reportLink = document.getElementById(p + 'OpenReport#text');
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
result.modal.hasReport = true;
}
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
const addInfo = document.getElementById(p + 'AdditionalInfo');
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
const srvText = document.getElementById(p + 'ServerText');
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
break;
}
}
}
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
if (stateWins.length) {
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
if (texts.length) result.stateText = texts;
}
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
})()`;
}
@@ -0,0 +1,187 @@
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Find the first grid cell on the form and return its center coords.
* Used as a fallback target for Alt+F when there's no search input.
*
* Returns `{ x, y } | null`.
*/
export function findFirstGridCellCoordsScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0);
if (!grid) return null;
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
if (!rows.length) return null;
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (!cells.length) return null;
const r = cells[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Find the grid cell of the first row in the column whose header text matches `field`
* (fuzzy: exact startsWith includes; normalizes ё/е and NBSP).
*
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
* so the caller can use DLB to switch FieldSelector after opening the dialog.
*
* Returns:
* - `{ x, y, needDlb? } ` coords to click (advanced search target)
* - `{ error }` `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
*/
export function findColumnFirstCellCoordsScript(formNum, field) {
return `(() => {
const p = 'form${formNum}_';
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0);
if (!grid) return { error: 'no_grid' };
const targetField = ${JSON.stringify(field)};
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
let colIndex = -1;
let startsWithIdx = -1;
let includesIdx = -1;
for (let i = 0; i < headers.length; i++) {
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
if (!t) continue;
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
if (tl === fl) { colIndex = i; break; }
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
}
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
if (!rows.length) return { error: 'no_rows' };
if (colIndex < 0) {
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (!cells.length) return { error: 'no_cells' };
const r = cells[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
}
const cells = [...rows[0].querySelectorAll('.gridBox')];
if (colIndex >= cells.length) return { error: 'cell_not_found' };
const r = cells[colIndex].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
*/
export function readFieldSelectorInfoScript(dialogForm) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
const dlb = document.getElementById(p + 'FieldSelector_DLB');
return {
current: fsInput?.value?.trim() || '',
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
};
})()`;
}
/**
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact includes,
* normalizes ё/е and NBSP).
*
* Returns:
* - `{ x, y, name }` coords + matched name to click
* - `{ error, available? }` `'no_dropdown'` or `'field_not_found'` with list of available names
*/
export function pickFieldInSelectorDropdownScript(field) {
return `(() => {
const edd = document.getElementById('editDropDown');
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(field.toLowerCase())});
const items = [...edd.querySelectorAll('div')].filter(el =>
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
const r = match.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
})()`;
}
/**
* Read advanced search dialog state FieldSelector value, Pattern input id+value,
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
*
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
*/
export function readFilterDialogInfoScript(dialogForm) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
const ptLabel = ptInput?.closest('label');
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
const isDate = btns.some(c => c.includes('iCalendB'));
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
return {
fieldSelector: fsInput?.value?.trim() || '',
patternValue: ptInput?.value?.trim() || '',
patternId: ptInput?.id || '',
isDate,
isRef
};
})()`;
}
/**
* Find the × close button on the filter badge whose title matches `field`
* (exact includes; normalizes ё/е and NBSP).
*
* Returns:
* - `{ x, y, field }` coords + actual field title from the badge
* - `{ error, available }` `'not_found'` with list of available badge titles
*/
export function findFilterBadgeCloseScript(formNum, field) {
return `(() => {
const p = 'form${formNum}_';
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(field.toLowerCase())});
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
for (const item of items) {
const titleEl = item.querySelector('.trainName');
const title = ny(norm(titleEl?.innerText).toLowerCase());
if (title === target || title.includes(target)) {
const close = item.querySelector('.trainClose');
if (close) {
const r = close.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
}
}
}
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
return { error: 'not_found', available };
})()`;
}
/**
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
* Returns `{ x, y } | null`.
*/
export function findFirstFilterBadgeCloseScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
.find(el => el.offsetWidth > 0);
if (!item) return null;
const close = item.querySelector('.trainClose');
if (!close) return null;
const r = close.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
@@ -0,0 +1,34 @@
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
/**
* Combined: detect form + read form + read open tabs.
* Single evaluate call instead of 3. Used by browser.getFormState().
*/
export function getFormStateScript() {
return `(() => {
${DETECT_FORM_FN}
${DETECT_FORMS_FN}
${READ_FORM_FN}
const formNum = detectForm();
const meta = detectForms();
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
const p = 'form' + formNum + '_';
const formData = readForm(p);
// Open tabs bar (present only when tab panel is enabled in 1C settings)
const openTabs = [];
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
const text = el.innerText?.trim();
if (!text) return;
const entry = { name: text };
if (el.classList.contains('select')) entry.active = true;
openTabs.push(entry);
});
const activeTab = openTabs.find(t => t.active)?.name || null;
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
if (meta.modal) result.modal = true;
if (openTabs.length) result.openTabs = openTabs;
return result;
})()`;
}
@@ -0,0 +1,647 @@
// web-test dom/forms v1.6 — 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 } from './_shared.mjs';
/**
* Detect the active form number.
* Picks the form with the most visible elements (excluding form0 = home page).
*/
export function detectFormScript() {
return `(() => {
${DETECT_FORM_FN}
return detectForm();
})()`;
}
/**
* Read full form state for a given form number.
* Uses shared READ_FORM_FN.
*/
export function readFormScript(formNum) {
const p = `form${formNum}_`;
return `(() => {
${READ_FORM_FN}
return readForm(${JSON.stringify(p)});
})()`;
}
/**
* Find a clickable element on the current form (button, hyperlink, tab, frame button).
* Returns { id, kind, name } for Playwright page.click(), or { error, available }.
* Supports synonym matching: visible text AND internal name from DOM ID.
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
*/
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
const p = ${JSON.stringify(p)};
const tableName = ${JSON.stringify(tableName || '')};
const gridSelector = ${JSON.stringify(gridSelector || '')};
const items = [];
// Buttons (a.press)
[...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
const span = el.querySelector('.submenuText') || el.querySelector('span');
const text = norm(span?.textContent) || norm(el.innerText);
if (!text && !el.classList.contains('pressCommand')) return;
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
items.push(item);
});
// Hyperlinks (staticTextHyper)
[...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const text = norm(el.innerText);
items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' });
});
// Frame buttons
[...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => {
const text = norm(el.innerText);
const idName = el.id.replace(p, '');
if (!text && !idName) return;
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
});
// Tumbler items (toggle switch segments)
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const text = norm(el.innerText);
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
});
// Checkboxes (div.checkbox) — match by label or internal name
[...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => {
const idName = el.id.replace(p, '');
const titleEl = document.getElementById(p + idName + '#title_text');
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
});
// Tabs (scoped to form)
[...document.querySelectorAll('[data-content]')].filter(el => {
if (el.offsetWidth === 0) return false;
let node = el.parentElement;
while (node) {
if (node.id && node.id.startsWith(p)) return true;
node = node.parentElement;
}
return false;
}).forEach(el => {
const r = el.getBoundingClientRect();
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
// Navigation panel items (FormNavigationPanel) — in parent page{N}
const formEl = document.querySelector('[id^="' + p + '"]');
if (formEl) {
let pageEl = formEl.parentElement;
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
if (pageEl) {
pageEl.querySelectorAll('.navigationItem').forEach(el => {
if (el.offsetWidth === 0) return;
const nameEl = el.querySelector('.navigationItemName');
const text = norm(nameEl?.innerText || '');
if (!text) return;
items.push({ id: el.id, name: text, label: '', kind: 'navigation' });
});
}
}
// When table is specified, scope button search to grid's parent container
if (gridSelector) {
const gridEl = document.querySelector(gridSelector);
if (gridEl) {
// Find parent container that has id with formPrefix and contains the grid
let container = gridEl.parentElement;
while (container && container !== document.body) {
if (container.id && container.id.startsWith(p)) break;
container = container.parentElement;
}
// Filter items to those inside the container
const containerItems = container && container !== document.body
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
: [];
// Try fuzzy match within container first
let cf = containerItems.find(i => i.name.toLowerCase() === target);
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
if (gridName) {
const prefixItems = items.filter(i => i.label && i.label.includes(gridName));
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
}
}
// Fall through to unscoped search
}
// Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip
// Skip includes() for short strings (< 4 chars) to avoid false positives
// e.g. "Да" matching "КомандаУстановитьВсе"
let found = items.find(i => i.name.toLowerCase() === target);
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target);
if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target));
if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target));
if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target));
if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target));
if (found) {
const res = { id: found.id, kind: found.kind, name: found.name };
if (found.x != null) { res.x = found.x; res.y = found.y; }
return res;
}
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
// Search ALL visible grids (or specific grid when table parameter is set)
let grids;
if (gridSelector) {
const g = document.querySelector(gridSelector);
grids = g ? [g] : [];
} else {
grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0);
}
for (const grid of grids) {
const body = grid.querySelector('.gridBody');
if (!body) continue;
const lines = [...body.querySelectorAll('.gridLine')];
for (const line of lines) {
const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean);
const firstCell = rowTexts[0]?.toLowerCase() || '';
const rowText = rowTexts.join(' ').toLowerCase();
if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) {
const imgBox = line.querySelector('.gridBoxImg');
const isGroup = imgBox?.querySelector('.gridListH') !== null;
const isParent = imgBox?.querySelector('.gridListV') !== null;
const isTreeNode = line.querySelector('.gridBoxTree') !== null;
const hasChildren = line.querySelector('[tree="true"]') !== null;
let kind;
if (isGroup) kind = 'gridGroup';
else if (isParent) kind = 'gridParent';
else if (isTreeNode && hasChildren) kind = 'gridTreeNode';
else kind = 'gridRow';
const r = line.getBoundingClientRect();
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
}
}
}
// Form input fields — LAST resort: focus a field by name/label without changing its value.
// Only when no table scope is given ("если нет уточнения таблицы"): grid cells are handled elsewhere.
// Reached only after every clickable target (button/link/tab/nav/grid row) failed to match,
// so collisions between a field name and a real control are unlikely.
const fields = [];
if (!tableName) {
[...document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]')].forEach(el => {
if (el.offsetWidth === 0) return;
// Skip inputs inside a grid — those are table cells, not form fields.
let n = el.parentElement; let inGrid = false;
while (n) { if (n.classList && n.classList.contains('grid')) { inGrid = true; break; } n = n.parentElement; }
if (inGrid) return;
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
fields.push({ id: el.id, name: idName, label });
});
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
if (!ff) ff = fields.find(f => f.label && f.label.toLowerCase().startsWith(target));
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
}
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
for (const f of fields) { const nm = f.label || f.name; if (nm && !available.includes(nm)) available.push(nm); }
return { error: 'not_found', available };
})()`;
}
/**
* Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name.
* Returns { fieldName, buttonId, buttonType } or { error, available }.
*/
export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))};
const suffix = ${JSON.stringify(buttonSuffix)};
const allFields = [];
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
allFields.push({ name, label });
});
// Also collect checkboxes for DCS pair matching
const allCheckboxes = [];
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
allCheckboxes.push({ inputId: el.id, name, label });
});
// Build DCS pairs: checkbox label → paired value field
const dcsPairs = {};
for (const f of [...allFields, ...allCheckboxes]) {
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
if (!m) continue;
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
dcsPairs[m[1]][m[2]] = f;
}
let found = allFields.find(f => f.name.toLowerCase() === target);
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
// DCS pair: match checkbox or value label → resolve to paired value field
let dcsCheckbox = null;
if (!found) {
for (const pair of Object.values(dcsPairs)) {
const cb = pair['Использование'];
const val = pair['Значение'];
if (!cb || !val) continue;
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
found = val;
dcsCheckbox = cb;
break;
}
}
}
if (!found) {
return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) };
}
const btnId = p + found.name + '_' + suffix;
const btn = document.getElementById(btnId);
if (!btn || btn.offsetWidth === 0) {
return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name };
}
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
return result;
})()`;
}
/**
* Resolve field names to element IDs for Playwright page.fill().
* Returns [{ field, inputId, name, label }] or [{ field, error, available }].
* Supports synonym matching: internal name AND visible label.
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
*/
export function resolveFieldsScript(formNum, fields) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const fieldNames = ${JSON.stringify(Object.keys(fields))};
const results = [];
// Build field map with name + label for synonym matching
const allFields = [];
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
const titleEl = document.getElementById(p + name + '#title_text')
|| document.getElementById(p + name + '#title_div');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
const last = { inputId: el.id, name, label };
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
const cbEl = document.getElementById(p + name + '_CB');
if (cbEl?.offsetWidth > 0) {
last.hasPick = true;
if (cbEl.classList.contains('iCalendB')) last.isDate = true;
else if (cbEl.classList.contains('iCalcB')) last.isCalc = true;
}
allFields.push(last);
});
// Checkboxes
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
if (el.offsetWidth === 0) return;
const name = el.id.replace(p, '');
const titleEl = document.getElementById(p + name + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
});
// Radio button groups — base element = option 0, others are #N#radio
const radioSeen = new Set();
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
if (el.offsetWidth === 0) return;
const id = el.id.replace(p, '');
// Skip if already processed or if it's a sub-element (#N#radio)
const m = id.match(/^(.+?)#(\\d+)#radio$/);
const groupName = m ? m[1] : (!id.includes('#') ? id : null);
if (!groupName || radioSeen.has(groupName)) return;
radioSeen.add(groupName);
const titleEl = document.getElementById(p + groupName + '#title_text');
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
// Collect options: option 0 is the base element, options 1+ have #N#radio
const options = [];
// Option 0: base element
const base = document.getElementById(p + groupName);
if (base && base.classList.contains('radio') && base.offsetWidth > 0) {
const textEl = document.getElementById(p + groupName + '#0#radio_text');
options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') });
}
// Options 1+
for (let i = 1; i < 20; i++) {
const opt = document.getElementById(p + groupName + '#' + i + '#radio');
if (!opt || opt.offsetWidth === 0) break;
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
}
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
});
// Build DCS pairs: checkbox label → paired value field
const dcsPairs = {};
for (const f of allFields) {
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
if (!m) continue;
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
dcsPairs[m[1]][m[2]] = f;
}
for (const fieldName of fieldNames) {
const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, '');
// Fuzzy: exact name -> exact label -> includes name -> includes label
let found = allFields.find(f => f.name.toLowerCase() === target);
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
// DCS pair: match checkbox or value label → resolve to paired value field
if (!found) {
for (const pair of Object.values(dcsPairs)) {
const cb = pair['Использование'];
const val = pair['Значение'];
if (!cb || !val) continue;
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
found = val;
found._dcsCheckbox = cb;
break;
}
}
}
if (found) {
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
if (found.hasSelect) entry.hasSelect = true;
if (found.hasPick) entry.hasPick = true;
if (found.isDate) entry.isDate = true;
if (found.isCalc) entry.isCalc = true;
if (found._dcsCheckbox) {
entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked };
delete found._dcsCheckbox;
}
results.push(entry);
} else {
const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name);
results.push({ field: fieldName, error: 'not_found', available });
}
}
return results;
})()`;
}
/**
* Detect a new form opened above `prevFormNum`. Two modes:
* default (broad) counts any visible `[id]` element; finds dialogs whose
* `a.press` buttons have empty IDs. Used by selectValue / fillTableRow.
* `{ strict: true }` only counts visible interactive elements
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
*
* Returns the highest new form number or `null`.
*/
export function detectNewFormScript(prevFormNum, { strict = false } = {}) {
const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]';
const visibleCheck = strict
? 'el.offsetWidth === 0'
: 'el.offsetWidth === 0 && el.offsetHeight === 0';
return `(() => {
const forms = {};
document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => {
if (${visibleCheck}) return;
const m = el.id.match(/^form(\\d+)_/);
if (m) forms[m[1]] = true;
});
const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum});
return nums.length > 0 ? Math.max(...nums) : null;
})()`;
}
/**
* Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id).
* Returns `{ id, value } | null`.
*/
export function findSearchInputScript(formNum) {
return `(() => {
const p = 'form${formNum}_';
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
return el ? { id: el.id, value: el.value || '' } : null;
})()`;
}
/**
* Find a visible `a.press` button by its exact innerText (after trim).
* Returns `{ x, y } | null` for `page.mouse.click(x, y)`.
*
* Used for modal dialog buttons (Найти, OK) where page.click may be blocked.
*/
export function findNamedButtonScript(buttonText) {
return `(() => {
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)});
if (!btn) return null;
const r = btn.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.)
* on a search/filter dialog.
*
* Returns:
* - `{ already: true }` the group is disabled OR the radio is already selected
* - `{ x, y } | null` coords to click, or null if radio not present
*/
export function findCompareTypeRadioScript(dialogForm, radioIndex) {
return `(() => {
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
const group = document.getElementById(p + 'CompareType');
if (group && group.classList.contains('disabled')) return { already: true };
const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio');
if (!el || el.offsetWidth === 0) return null;
if (el.classList.contains('select')) return { already: true };
const r = el.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Is any element of `form{dialogForm}_` currently visible?
* Used to poll dialog dismissal after Escape.
*/
export function isFormVisibleScript(dialogForm) {
return `(() => {
const p = 'form${dialogForm}_';
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
})()`;
}
/**
* Find the Pattern input id on a search/filter dialog. Returns `id | null`.
*/
export function findPatternInputIdScript(dialogForm) {
return `(() => {
const p = 'form${dialogForm}_';
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
return el ? el.id : null;
})()`;
}
/**
* Is the given form a type selection dialog ("Выбор типа данных")?
*
* Detection signals (any one is sufficient):
* - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK")
* - `form{N}_ValueList` grid exists (specific to type/value list dialogs)
* - window title contains "Выбор типа" on a visible `.toplineBoxTitle`
*
* Returns boolean.
*/
export function isTypeDialogScript(formNum) {
return `(() => {
const p = 'form' + ${formNum} + '_';
const hasOK = !!document.getElementById(p + 'OK');
const hasValueList = !!document.getElementById(p + 'ValueList');
const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
.some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
return hasOK || hasValueList || hasTitle;
})()`;
}
/**
* Click the "Показать все" / "Show all" link inside the "нет в списке"
* cloud popup via `dispatchEvent`. Returns boolean whether clicked.
*/
export function clickShowAllInNotInListCloudScript() {
return `(() => {
for (const el of document.querySelectorAll('div')) {
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
const s = getComputedStyle(el);
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
if ((parseInt(s.zIndex) || 0) < 100) continue;
if (!(el.innerText || '').includes('нет в списке')) continue;
const links = [...el.querySelectorAll('a, span, div')]
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
const showAll = links.find(e => {
const t = (e.innerText?.trim() || '').toLowerCase();
return t === 'показать все' || t === 'show all';
});
if (showAll) {
const r = showAll.getBoundingClientRect();
const opts = { bubbles:true, cancelable:true,
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
showAll.dispatchEvent(new MouseEvent('click', opts));
return true;
}
return false;
}
return false;
})()`;
}
/**
* Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div
* (absolute/fixed, high z-index) whose text contains "нет в списке".
* Returns boolean.
*/
export function isNotInListCloudVisibleScript() {
return `(() => {
const divs = document.querySelectorAll('div');
for (const el of divs) {
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
const style = getComputedStyle(el);
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
const z = parseInt(style.zIndex) || 0;
if (z < 100) continue;
if ((el.innerText || '').includes('нет в списке')) return true;
}
return false;
})()`;
}
/**
* Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible.
* Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number.
* Returns the form number or `null`.
*/
export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) {
return `(() => {
for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) {
const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)});
if (btn && btn.offsetWidth > 0) return n;
}
return null;
})()`;
}
/**
* Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`.
*
* `typeNorm` should already be lowercased, NBSP-normalized, ёе normalized (use `normYo`).
*
* Returns `{ visible: string[], matches: Array<{ text, x, y }> }`.
*/
export function readTypeDialogVisibleRowsScript(formNum, typeNorm) {
return `(() => {
const grid = document.getElementById('form${formNum}_ValueList');
if (!grid) return { visible: [], matches: [] };
const body = grid.querySelector('.gridBody');
if (!body) return { visible: [], matches: [] };
const lines = body.querySelectorAll('.gridLine');
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim();
const typeNorm = ${JSON.stringify(typeNorm)};
const visible = [];
const matches = [];
for (const line of lines) {
const text = norm(line.innerText);
if (!text) continue;
visible.push(text);
if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) {
const r = line.getBoundingClientRect();
matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
}
}
return { visible, matches };
})()`;
}
@@ -0,0 +1,292 @@
// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
// the page (this matches the implicit "current grid" used by row-fill).
/** Inline JS fragment that resolves the target grid into `const grid`. */
function gridResolver(gridSelector) {
return gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
}
/**
* Read the grid's column header texts paired with their `colindex` attribute,
* fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in
* left-to-right colindex order.
*
* Keys that don't match a column get colindex `999` (pushed to the end);
* caller is expected to preserve their original relative order.
*
* Returns `string[] | null` (null when no grid or no head).
*/
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
if (!head) return null;
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
const ci = parseInt(box.getAttribute('colindex') || '-1');
if (t) cols.push({ text: t, colindex: ci });
});
const keys = ${JSON.stringify(fieldKeys)};
const mapped = keys.map(k => {
const exact = cols.find(c => c.text === k);
if (exact) return { key: k, colindex: exact.colindex };
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
return { key: k, colindex: inc ? inc.colindex : 999 };
});
mapped.sort((a, b) => a.colindex - b.colindex);
return mapped.map(m => m.key);
})()`;
}
/**
* Resolve cell coords for row `row` by matching the first column whose header
* fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second
* visible (non-`.gridBoxComp`) box when no header matches.
*
* Returns one of:
* - `{ x, y, currentText }` coords + cell text
* - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }`
* - `{ error: 'row_out_of_range', total }`
*/
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return { error: 'no_grid_body' };
// Read column headers to find target colindex
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
});
const keys = ${JSON.stringify(fieldKeys)};
let targetColindex = null;
for (const key of keys) {
const exact = cols.find(c => c.text === key);
if (exact) { targetColindex = exact.colindex; break; }
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
if (inc) { targetColindex = inc.colindex; break; }
}
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Find body cell by colindex (reliable across merged headers)
let box = null;
if (targetColindex != null) {
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
}
// Fallback: second visible box (skip checkbox/N column)
if (!box) {
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
box = boxes.length > 1 ? boxes[1] : boxes[0];
}
if (!box) return { error: 'no_cell' };
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
})()`;
}
/**
* Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra
* "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches
* key "ГруппаКонтрагентов").
*
* Returns `{ x, y, currentText } | null`.
*/
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return null;
const headLine = head.querySelector('.gridLine') || head;
const cols = [];
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const t = box.querySelector('.gridBoxText');
const ci = box.getAttribute('colindex');
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
});
const kl = ${JSON.stringify(key.toLowerCase())};
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
let targetColindex = null;
const exact = cols.find(c => c.text === kl);
if (exact) targetColindex = exact.colindex;
else {
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
if (inc) targetColindex = inc.colindex;
}
if (targetColindex == null) return null;
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return null;
const line = rows[${row}];
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
if (!box) return null;
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
})()`;
}
/**
* Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing
* a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for
* direct click).
*
* Returns `null` when there's no cell, or the cell isn't a checkbox cell.
*/
export function findCheckboxAtPointScript(x, y) {
return `(() => {
const el = document.elementFromPoint(${x}, ${y});
const cell = el?.closest('.gridBox');
if (!cell) return null;
const chk = cell.querySelector('.checkbox');
if (!chk) return null;
const r = chk.getBoundingClientRect();
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
})()`;
}
/**
* Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row
* OTHER than `row` (used to commit an edit by clicking off the edited row
* Escape would cancel in tree grids).
*
* For `row === 0`, targets row 1; otherwise targets row 0.
*
* Returns `{ x, y } | null` (null when there's no other row).
*/
export function findRowCommitClickCoordsScript(gridSelector, row) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const rows = [...body.querySelectorAll('.gridLine')];
const otherIdx = ${row} === 0 ? 1 : 0;
const other = rows[otherIdx];
if (!other) return null;
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
if (!box) return null;
const r = box.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or
* `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics:
* - `{ inEdit: true }` good
* - `{ inEdit: false, tag: 'DIV' }` active element wasn't INPUT
* - `{ inEdit: false, hint: 'input not inside grid' }` input but no grid ancestor
*/
export function getGridEditCheckScript() {
return `(() => {
const f = document.activeElement;
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
let node = f;
while (node) {
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
node = node.parentElement;
}
return { inEdit: false, hint: 'input not inside grid' };
})()`;
}
/**
* Read the currently focused element if it's an editable grid cell (INPUT or
* TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by
* matching x-overlap of the input's bounding rect against header boxes.
*
* Returns one of:
* - `{ tag: 'INPUT', id, fullName, headerText }` editable cell
* - `{ tag: 'DIV' | 'BODY' | ... }` focused but not an editable cell
* - `{ tag: 'none' }` nothing focused
*
* `fullName` strips both `form{N}_` prefix and `_i{M}` suffix.
*/
export function readActiveGridCellScript() {
return `(() => {
const f = document.activeElement;
if (!f) return { tag: 'none' };
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
if (inGrid) {
let headerText = '';
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
if (grid) {
const fr = f.getBoundingClientRect();
const head = grid.querySelector('.gridHead');
const hl = head?.querySelector('.gridLine') || head;
if (hl) for (const h of hl.children) {
if (h.offsetWidth === 0) continue;
const hr = h.getBoundingClientRect();
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
const t = h.querySelector('.gridBoxText');
headerText = (t || h).innerText?.trim() || '';
break;
}
}
}
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
let buttonKind = null;
const base = f.id.replace(/_i\\d+$/, '');
const dlb = document.getElementById(base + '_DLB');
const cb = document.getElementById(base + '_CB');
if (dlb && dlb.offsetWidth > 0) buttonKind = 'ref';
else if (cb && cb.offsetWidth > 0) {
if (cb.classList.contains('iCalcB')) buttonKind = 'calc';
else if (cb.classList.contains('iCalendB')) buttonKind = 'date';
else buttonKind = 'choice';
}
return {
tag: 'INPUT', id: f.id,
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
headerText, buttonKind
};
}
}
return { tag: f.tagName || 'none' };
})()`;
}
/**
* Return center coords of the element with the given id.
* Returns `{ x, y } | null`.
*/
export function getElementCenterCoordsByIdScript(elementId) {
return `(() => {
const el = document.getElementById(${JSON.stringify(elementId)});
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
})()`;
}
@@ -0,0 +1,755 @@
// web-test dom/grid v1.9 — grid resolution + table reading + edit-time helpers
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Resolve a specific grid by semantic name (table parameter).
* Cascade: exact gridName match gridName contains column contains.
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
*/
export function resolveGridScript(formNum, tableName) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
const norm = s => (s || '').replace(/ё/gi, 'е');
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
const infos = allGrids.map((g, idx) => {
const gridId = g.id || '';
const gridName = gridId.replace(p, '');
const head = g.querySelector('.gridHead');
const columns = [];
if (head) {
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (text) columns.push(text);
});
}
// Visual label from group title element
const titleEl = document.getElementById(p + gridName + '#title_div')
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
return { idx, gridId, gridName, label, columns, el: g };
});
// 1. Exact gridName match (case-insensitive)
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
// 2. Exact label match
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
// 3. gridName contains target
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
// 4. Label contains target
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
// 5. Any column contains target
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
if (found) {
return {
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
gridId: found.gridId,
gridName: found.gridName,
gridIndex: found.idx,
columns: found.columns
};
}
return {
error: 'not_found',
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
};
})()`;
}
/**
* Read table/grid data with pagination.
* Parses grid.innerText \n separates rows, \t separates cells.
* First row = column headers.
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
*/
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
const name = grid.id ? grid.id.replace(p, '') : '';
// Detect a "picture value" cell: a sprite from a picture collection
// (.gridBoxImg .dIB with background-image .../pictureCollection/picture/<id>?...&gx=<N>).
// Excludes decorative tree/group markers (gridListH/gridListV/[tree]/gridBoxTree).
// Returns { gx } — the sprite frame index that encodes the cell state, or null.
function picInfo(cell) {
if (!cell) return null;
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
const dib = cell.querySelector('.gridBoxImg .dIB');
if (!dib) return null;
const bg = dib.style.backgroundImage || '';
if (!bg.includes('pictureCollection/picture/')) return null;
const m = bg.match(/[?&]gx=(\\d+)/);
return { gx: m ? m[1] : '0' };
}
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) {
// Fallback: innerText-based (for non-standard grids)
const gText = grid.innerText?.trim() || '';
const lines = gText.split('\\n').filter(Boolean);
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
hint: 'Grid has no gridHead/gridBody structure' };
}
// Extract column headers with X-coordinates for alignment
const columns = [];
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!text) {
// Unnamed column — check if data cells contain checkboxes or pictures.
// Picture columns have no header text (only an icon + a title tooltip); 1С
// doesn't expose the technical column name in the DOM, so we name them by
// the header's title attribute, falling back to '(picture)'.
const firstLine = body?.querySelector('.gridLine');
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
const r = box.getBoundingClientRect();
if (cells[idx]?.querySelector('.checkbox')) {
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
} else if (picInfo(box) || picInfo(cells[idx])) {
let title = (box.getAttribute('title') || '').trim() || '(picture)';
// Disambiguate duplicate picture-column names with a numeric suffix.
if (columns.some(c => c.text === title)) {
let n = 2;
while (columns.some(c => c.text === title + ' ' + n)) n++;
title = title + ' ' + n;
}
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
}
return;
}
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
});
// Multi-row grid support: detect stacked/merged headers.
// Group headers by X-range. For each group, count data sub-rows from first line.
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
const xGroups = new Map();
columns.forEach(c => {
const key = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGroups.has(key)) xGroups.set(key, []);
xGroups.get(key).push(c);
});
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
const firstDataLine = body?.querySelector('.gridLine');
const subRowMap = new Map();
if (firstDataLine) {
[...firstDataLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
for (const [key, hdrs] of xGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
if (!subRowMap.has(key)) subRowMap.set(key, []);
subRowMap.get(key).push({ y: r.y });
break;
}
}
});
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
}
const multiRowGroups = new Map();
for (const [key, hdrs] of xGroups) {
const subs = subRowMap.get(key);
if (!subs || subs.length <= 1) continue;
if (hdrs.length >= 2) {
multiRowGroups.set(key, hdrs);
} else if (hdrs.length === 1 && subs.length > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
const expanded = [];
for (let si = 0; si < subs.length; si++) {
const numbered = {
text: base.text + ' ' + (si + 1),
x: base.x, w: base.w, right: base.right,
y: base.y + si, h: base.h / subs.length, _subIdx: si
};
columns.splice(baseIdx + si, 0, numbered);
expanded.push(numbered);
}
multiRowGroups.set(key, expanded);
}
}
function matchColumn(cellX, cellW, cellY) {
const cx = cellX + cellW / 2;
for (const [key, hdrs] of multiRowGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
const subs = subRowMap.get(key);
if (subs) {
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
}
let best = hdrs[0], bestDist = Infinity;
for (const h of hdrs) {
const dist = Math.abs(cellY - h.y);
if (dist < bestDist) { bestDist = dist; best = h; }
}
return best;
}
}
return columns.find(c => cx >= c.x && cx < c.right);
}
// Extract data rows from gridBody
const allLines = body.querySelectorAll('.gridLine');
const total = allLines.length;
const rows = [];
const end = Math.min(${offset} + ${maxRows}, total);
for (let i = ${offset}; i < end; i++) {
const line = allLines[i];
if (!line) break;
const row = {};
columns.forEach(c => { row[c.text] = ''; });
[...line.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const chk = box.querySelector('.checkbox');
let val;
if (chk) {
val = chk.classList.contains('select') ? 'true' : 'false';
} else {
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!val) {
// Empty text → maybe a picture cell. 'pic:<gx>' encodes the sprite frame
// (state). Absent picture stays '' (truthy check distinguishes presence).
const pic = picInfo(box);
if (pic) val = 'pic:' + pic.gx;
else return;
}
}
// Match cell to column by X+Y overlap (multi-row aware)
const r = box.getBoundingClientRect();
const col = matchColumn(r.x, r.width, r.y);
if (col) {
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
}
});
// Detect row kind: group (gridListH), parent/up (gridListV), or element
const imgBox = line.querySelector('.gridBoxImg');
if (imgBox) {
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
}
// Tree mode: detect expand/collapse state and indent level
const treeBox = line.querySelector('.gridBoxTree');
if (treeBox) {
const treeIcon = imgBox?.querySelector('[tree="true"]');
if (treeIcon) {
const bg = treeIcon.style.backgroundImage || '';
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
}
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
}
// Selection state: selRow = selected row in grid
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
rows.push(row);
}
const isTree = !!body.querySelector('.gridBoxTree');
const hasGroups = rows.some(r => r._kind === 'group');
// Virtualization-aware hasMore signal. Three sources in priority order:
// 1. Dynamic-list turn buttons (#vertButtonScroll_<gridId>, sibling of grid).
// Buttons carry data-home/data-up (above) and data-down/data-end (below);
// class "disabled" on a direction means nothing to show there.
// 2. Tabular-section scrollbar (#vertScroll_<gridId>, class scrollV) —
// track-back/track-next pixel heights tell us above/below precisely.
// 3. Fallback: scrollHeight>clientHeight for "below"; "above" unknown.
let hasMore;
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
if (turnsBox && turnsBox.offsetHeight > 0) {
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
hasMore = {
above: [...upBtns].some(b => !b.classList.contains('disabled')),
below: [...dnBtns].some(b => !b.classList.contains('disabled')),
};
} else {
const vsId = 'vertScroll_' + grid.id;
const vs = document.getElementById(vsId);
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
hasMore = { above: back > 0, below: next > 0 };
} else {
hasMore = { below: body.scrollHeight > body.clientHeight };
}
}
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
if (isTree) result.viewMode = 'tree';
if (hasGroups) result.hierarchical = true;
return result;
})()`;
}
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
// the page (this matches the implicit "current grid" used by row-fill).
/** Inline JS fragment that resolves the target grid into `const grid`. */
function gridResolver(gridSelector) {
return gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
}
/**
* Find center coords of a target row for click-select (used by deleteTableRow).
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
*
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
*/
export function findDeleteRowCoordsScript(gridSelector, row) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const body = grid.querySelector('.gridBody');
if (!body) return { error: 'no_grid_body' };
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
// Skip first column (row number / checkbox) — pick second visible box
const box = boxes.length > 1 ? boxes[1] : boxes[0];
if (!box) return { error: 'no_cell' };
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
})()`;
}
/**
* Count `.gridLine` rows in the body of the target grid.
* Returns the row count, or `0` when grid/body absent.
*/
export function countGridRowsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
const body = grid?.querySelector('.gridBody');
return body ? body.querySelectorAll('.gridLine').length : 0;
})()`;
}
/**
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
* Returns boolean.
*/
export function isTreeGridScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
return grid ? !!grid.querySelector('.gridBoxTree') : false;
})()`;
}
/**
* Return center coords of the grid's `.gridHead` element.
* Used as a click target to commit a pending cell edit (clicking the header
* defocuses the input without selecting another row).
*
* Returns `{ x, y } | null`.
*/
export function findGridHeadCenterCoordsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
if (!head) return null;
const r = head.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`;
}
/**
* Return the index of the currently selected row in the target grid, or
* fall back to the last row when nothing is selected.
*
* Returns row index, or `-1` when no rows.
*/
export function getSelectedOrLastRowIndexScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return -1;
const body = grid.querySelector('.gridBody');
if (!body) return -1;
const lines = [...body.querySelectorAll('.gridLine')];
const sel = lines.findIndex(l => l.classList.contains('selected'));
return sel >= 0 ? sel : lines.length - 1;
})()`;
}
/**
* Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive,
* NBSP-normalised). Match order: exact startsWith includes.
*
* When `searchLower` is empty, returns coords of the first row (fallback).
*
* Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`.
*/
export function scanGridRowsScript(formNum, searchLower) {
return `(() => {
const p = 'form${formNum}_';
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = [...body.querySelectorAll('.gridLine')];
if (!lines.length) return { rowCount: 0 };
const searchLower = ${JSON.stringify(searchLower || '')};
let sel = null;
if (searchLower) {
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) }));
sel = rowData.find(r => r.text === searchLower)?.el
|| rowData.find(r => r.text.startsWith(searchLower))?.el
|| rowData.find(r => r.text.includes(searchLower))?.el;
} else {
sel = lines[0]; // empty search → first row
}
if (!sel) return null;
const imgBox = sel.querySelector('.gridBoxImg');
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
const r = sel.getBoundingClientRect();
return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup };
})()`;
}
// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ───────
/**
* Resolve a target cell in a grid by (row, column).
* - `column` matched: exact (case+ё-insensitive) endsWith ' / X' includes.
* - `row`: number = index in current DOM window; object = {col: value, ...} filter
* (matches first non-group/parent row where every column condition passes).
*
* Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`.
*
* Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport.
* Callers should horizontally scroll first if `visible === false`.
*/
export function findGridCellScript(formNum, gridSelector, { row, column }) {
const p = `form${formNum}_`;
return `(() => {
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim();
const lo = s => norm(s).toLowerCase();
const p = ${JSON.stringify(p)};
const grid = ${gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
if (!grid) return { error: 'no_grid' };
const head = grid.querySelector('.gridHead');
const body = grid.querySelector('.gridBody');
if (!head || !body) return { error: 'no_grid_structure' };
// Header X-ranges (mirror of readTableScript logic, simplified). We also
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
// columns can share X coordinates after horizontal scroll, so cell matching
// must respect the frozen/scrollable partition.
const headLine = head.querySelector('.gridLine') || head;
const headers = [...headLine.children]
.filter(c => c.offsetWidth > 0)
.map(c => {
const textEl = c.querySelector('.gridBoxText');
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
// Picture/icon columns have no header text — fall back to the title tooltip
// (mirrors readTable naming) so they can still be targeted for clicking.
const title = (c.getAttribute('title') || '').trim();
const r = c.getBoundingClientRect();
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
})
.filter(h => h.name);
const resolveCol = (name) => {
const suffix = ' / ' + name;
const cand = h => [h.text, h.title].filter(Boolean);
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
};
const targetCol = ${JSON.stringify(column)};
const col = resolveCol(targetCol);
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
const lines = [...body.querySelectorAll('.gridLine')];
if (lines.length === 0) return { error: 'empty_grid' };
// Match cell to column by X overlap, but only among cells with the same
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
// cell may have the same x as a frozen one — without this guard cellAtColX
// would silently return the frozen cell for a scrollable header.
const cellAtColX = (line, c) => [...line.children]
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
.find(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2;
return cx >= c.x && cx < c.right;
});
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
const target = ${JSON.stringify(row)};
let line, rowIdx;
if (typeof target === 'number') {
if (target < 0 || target >= lines.length) {
return { error: 'row_out_of_range', row: target, loaded: lines.length };
}
line = lines[target];
rowIdx = target;
} else if (target && typeof target === 'object') {
const entries = Object.entries(target);
const colsByKey = {};
for (const [k] of entries) {
const c = resolveCol(k);
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
colsByKey[k] = c;
}
const matches = (ln) => {
for (const [k, v] of entries) {
const c = colsByKey[k];
const cell = cellAtColX(ln, c);
const txt = cellText(cell);
const wanted = lo(v);
if (!txt) return false;
const t = txt.toLowerCase();
if (!(t === wanted || t.includes(wanted))) return false;
}
return true;
};
rowIdx = lines.findIndex(matches);
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
line = lines[rowIdx];
} else {
return { error: 'invalid_row_type' };
}
const cell = cellAtColX(line, col);
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
const r = cell.getBoundingClientRect();
const gridBox = grid.getBoundingClientRect();
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
// when the rest scrolls horizontally. For non-frozen cells, "visible" means
// inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells
// are always visible by definition.
const isFixed = cell.classList.contains('gridBoxFix');
let scrollableLeft = gridBox.x;
if (!isFixed) {
[...line.children].forEach(b => {
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
const br = b.getBoundingClientRect();
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
}
});
}
// "Visible enough to click" — the cell's CENTER is inside the scrollable area
// and the cell's right edge is inside the grid. Strict left-edge check would
// reject cells that 1С rendered touching the frozen-column boundary (off by 1px).
const center = r.x + r.width / 2;
const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width);
return {
x: Math.round(r.x + r.width / 2),
y: Math.round(r.y + r.height / 2),
cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width),
gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width),
scrollableLeft: Math.round(scrollableLeft),
columnText: col.name, rowIdx, isFixed,
cellText: cellText(cell),
visible
};
})()`;
}
/**
* Pick coordinates for a focus-click on a safe cell within the grid.
*
* Used both for vertical reveal-loop focus and for horizontal-scroll edge focus.
* The caller passes a profile that selects which row, which cells to exclude,
* and (for horizontal scroll) which edge of the row to take.
*
* @param {string} gridSelector
* @param {object} opts
* @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row.
* @param {'ArrowRight'|'ArrowLeft'} [opts.direction]
* - When set, restricts to non-frozen FULLY visible cells and picks the edge
* cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft).
* - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles).
*
* Always prefers non-checkbox cells (center-click on a checkbox would toggle it).
*
* Returns `{ x, y } | null`.
*/
export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = [...body.querySelectorAll('.gridLine')];
if (!lines.length) return null;
const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)};
const direction = ${direction ? JSON.stringify(direction) : 'null'};
const line = (rowIdx != null && lines[rowIdx])
|| lines.find(ln => {
const imgBox = ln.querySelector('.gridBoxImg');
return !imgBox?.querySelector('.gridListH, .gridListV');
})
|| lines[0];
if (!line) return null;
let candidates;
if (direction) {
// Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen.
const gridBox = grid.getBoundingClientRect();
let scrollableLeft = gridBox.x;
[...line.children].forEach(b => {
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
const br = b.getBoundingClientRect();
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
}
});
const visible = [...line.children]
.filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix'))
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }))
.filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width));
if (!visible.length) return null;
visible.sort((a, b) => a.r.x - b.r.x);
candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible;
} else {
// Generic focus mode (used by reveal-loop): pick the FIRST visible cell —
// typically a Reference column (Номенклатура in документах) which doesn't
// auto-enter edit mode on click. Number/Date/String cells auto-edit and
// break subsequent PageDown navigation.
// For tree grids (presence of .gridBoxTree), skip first column to avoid
// toggling expand/collapse of the row.
const isTree = !!body.querySelector('.gridBoxTree');
const cells = [...line.children]
.filter(b => b.offsetWidth > 0)
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }));
if (!cells.length) return null;
candidates = isTree && cells.length > 1 ? cells.slice(1) : cells;
}
const pick = candidates.find(v => !v.checkbox) || candidates[0];
if (!pick) return null;
return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) };
})()`;
}
/**
* Snapshot grid state for reveal-loop end detection.
* Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`.
*
* `firstText`/`lastText` use the first cell's `.gridBoxText` content.
* `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight.
*/
export function snapshotGridScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const lines = body.querySelectorAll('.gridLine');
// Full-row signature: join EVERY cell's text, not just the first column.
// A low-cardinality first column (e.g. all "Товар 0X") would otherwise make
// two different windows look identical and abort the reveal-loop early.
const txt = ln => ln ? [...ln.querySelectorAll('.gridBoxText')].map(b => (b.innerText || '').trim()).join('|') : '';
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
let hasBelow;
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
if (turnsBox && turnsBox.offsetHeight > 0) {
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
hasBelow = [...dnBtns].some(b => !b.classList.contains('disabled'));
} else {
const vs = document.getElementById('vertScroll_' + grid.id);
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0;
} else {
hasBelow = body.scrollHeight > body.clientHeight;
}
}
return {
firstText: txt(lines[0]),
lastText: txt(lines[lines.length - 1]),
lineCount: lines.length,
selIdx,
hasBelow
};
})()`;
}
/**
* Resolve the click target kind for `clickElement({row, column})`.
*
* Routing:
* - `tableName` specified: try to match a visible grid by name (exact contains).
* If matched grid. Else if form has a spreadsheet iframe spreadsheet. Else error.
* - `tableName` omitted: spreadsheet iframe present spreadsheet (backward-compat).
* Else first visible grid. Else error.
*
* Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`.
*/
export function resolveCellTargetScript(formNum, tableName) {
const p = `form${formNum}_`;
return `(() => {
const p = ${JSON.stringify(p)};
const tableName = ${JSON.stringify(tableName || '')};
// Spreadsheet = iframe under form prefix with non-trivial width.
const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => {
if (f.offsetWidth < 100) return false;
let el = f.parentElement;
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
if (el.id && el.id.startsWith(p)) return true;
}
return false;
});
const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase();
if (tableName) {
const target = norm(tableName);
const matched = grids.find(g => norm(g.id.replace(p, '')) === target)
|| grids.find(g => norm(g.id.replace(p, '')).includes(target));
if (matched) {
return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') };
}
if (hasSpreadsheet) return { kind: 'spreadsheet' };
return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) };
}
if (hasSpreadsheet) return { kind: 'spreadsheet' };
if (grids.length > 0) {
const g = grids[0];
return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') };
}
return { error: 'no_spreadsheet_or_grid' };
})()`;
}
@@ -0,0 +1,93 @@
// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/** Read sections panel (left sidebar). */
export function readSectionsScript() {
return `(() => {
const sections = [];
document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => {
const entry = { name: el.innerText?.trim() || '' };
if (el.classList.contains('select')) entry.active = true;
sections.push(entry);
});
return sections;
})()`;
}
/** Read open tabs bar. */
export function readTabsScript() {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const tabs = [];
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
const text = norm(el.innerText);
if (!text) return;
const entry = { name: text };
if (el.classList.contains('select')) entry.active = true;
tabs.push(entry);
});
return tabs;
})()`;
}
/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */
export function switchTabScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText));
let best = tabs.find(el => norm(el.innerText).toLowerCase() === target);
if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target));
if (best) { best.click(); return norm(best.innerText); }
return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) };
})()`;
}
/** Read commands in the function panel (current section). */
export function readCommandsScript() {
return `(() => {
const groups = [];
const container = document.querySelector('#funcPanel_container table tr');
if (!container) return groups;
for (const td of container.children) {
const commands = [];
td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => {
if (el.offsetWidth === 0) return;
commands.push(el.innerText?.trim() || '');
});
if (commands.length > 0) groups.push(commands);
}
return groups;
})()`;
}
/**
* Navigate to a section by name (fuzzy match).
* Returns the matched section name, or { error, available }.
*/
export function navigateSectionScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))};
const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
})()`;
}
/**
* Open a command from function panel by name (fuzzy match).
*/
export function openCommandScript(name) {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0);
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
})()`;
}
@@ -0,0 +1,149 @@
// web-test dom/submenu v1.0 — popup/submenu reading and clicking
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
* Read open popup/submenu items.
* Looks for absolutely positioned visible popup containers with a.press items inside.
* Returns [{ id, name }] or { error }.
*/
export function readSubmenuScript() {
return `(() => {
const items = [];
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
// 1. DLB dropdown (#editDropDown with .eddText items)
const edd = document.getElementById('editDropDown');
if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) {
edd.querySelectorAll('.eddText').forEach(el => {
if (el.offsetWidth === 0) return;
const text = norm(el.innerText);
if (!text) return;
const r = el.getBoundingClientRect();
items.push({ id: '', name: text, kind: 'dropdown',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
// Detect "Показать все" link in EDD footer
// Structure: div.eddBottom > div > span.hyperlink "Показать все"
let showAllEl = edd.querySelector('.eddBottom .hyperlink');
if (!showAllEl || showAllEl.offsetWidth === 0) {
// Fallback: scan all visible elements for text match
const candidates = [...edd.querySelectorAll('a.press, a, span, div')]
.filter(el => el.offsetWidth > 0 && el.children.length === 0);
showAllEl = candidates.find(el => {
const t = norm(el.innerText).toLowerCase();
return t === 'показать все' || t === 'show all';
});
}
if (showAllEl) {
const r = showAllEl.getBoundingClientRect();
items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
}
if (items.length > 0) return items;
}
// 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items)
// Read ALL visible high-z clouds (main menu + nested submenus)
const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0);
const seen = new Set();
clouds.forEach(c => {
const z = parseInt(getComputedStyle(c).zIndex) || 0;
if (z <= 1000) return;
c.querySelectorAll('.submenuText').forEach(el => {
if (el.offsetWidth === 0) return;
const text = norm(el.innerText);
if (!text || seen.has(text)) return;
seen.add(text);
const block = el.closest('.submenuBlock');
if (block && block.classList.contains('submenuBlockDisabled')) return;
const hasSub = block && /_sub$/.test(block.id);
const r = el.getBoundingClientRect();
items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
});
if (items.length > 0) return items;
// 3. Submenu popups — find the topmost positioned container with non-form a.press items
const popups = [...document.querySelectorAll('div')].filter(c => {
const style = getComputedStyle(c);
return (style.position === 'absolute' || style.position === 'fixed')
&& c.offsetWidth > 0 && c.offsetHeight > 0;
}).sort((a, b) => {
const za = parseInt(getComputedStyle(a).zIndex) || 0;
const zb = parseInt(getComputedStyle(b).zIndex) || 0;
return zb - za;
});
for (const container of popups) {
// Only direct a.press children or those not nested in another positioned div
const menuItems = [...container.querySelectorAll('a.press')].filter(el => {
if (el.offsetWidth === 0) return false;
if (el.id && /^form\\d+_/.test(el.id)) return false;
// Skip if this a.press is inside a deeper positioned container
let parent = el.parentElement;
while (parent && parent !== container) {
const ps = getComputedStyle(parent).position;
if (ps === 'absolute' || ps === 'fixed') return false;
parent = parent.parentElement;
}
return true;
});
if (menuItems.length < 2) continue; // Not a real menu
const seen = new Set();
menuItems.forEach(el => {
const text = norm(el.innerText);
if (!text) return;
if (seen.has(text)) return;
seen.add(text);
const r = el.getBoundingClientRect();
items.push({ id: el.id || '', name: text, kind: 'submenu',
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
if (items.length > 0) break; // Found the popup menu
}
if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' };
return items;
})()`;
}
/**
* Click a popup/dropdown item by text match (evaluate-based for items without IDs).
* Returns true if clicked, false if not found.
*/
export function clickPopupItemScript(text) {
return `(() => {
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
// 1. DLB dropdown (#editDropDown .eddText items)
const edd = document.getElementById('editDropDown');
if (edd && edd.offsetWidth > 0) {
for (const el of edd.querySelectorAll('.eddText')) {
if (el.offsetWidth === 0) continue;
const t = el.innerText?.trim() || '';
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
el.click();
return t;
}
}
}
// 2. Submenu popups (a.press in absolutely positioned containers)
const containers = [...document.querySelectorAll('div')].filter(c => {
const style = getComputedStyle(c);
return (style.position === 'absolute' || style.position === 'fixed')
&& c.offsetWidth > 0 && c.offsetHeight > 0;
});
for (const container of containers) {
const items = [...container.querySelectorAll('a.press')]
.filter(el => el.offsetWidth > 0);
for (const el of items) {
const t = el.innerText?.trim() || '';
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
el.click();
return t;
}
}
}
return null;
})()`;
}
@@ -0,0 +1,129 @@
// web-test core/click v1.22 — clickElement dispatcher: routes to spreadsheet / popup / 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';
import {
detectFormScript, findClickTargetScript, resolveGridScript,
readSubmenuScript, resolveCellTargetScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
import { waitForStable } from './wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import { modifierClick, returnFormState } from './helpers.mjs';
import {
clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget,
} from '../table/click-row.mjs';
import { clickGridCell } from '../table/click-cell.mjs';
import {
clickConfirmationButton, tryClickPopupItem,
} from '../forms/click-popup.mjs';
import { clickFormTarget, focusFormField } from '../forms/click-form.mjs';
import {
clickSpreadsheetCell, findSpreadsheetCellByText,
} from '../spreadsheet/spreadsheet.mjs';
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
* First argument can also be an object { row, column } to click a cell in a SpreadsheetDocument (отчёт) or a form grid (таблица/табчасть). */
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, scroll, timeout } = {}) {
ensureConnected();
// Dispatch to cell handler when first arg is { row, column }.
// Routing (see resolveCellTargetScript):
// - `table` named: matches grid → grid cell; falls back to spreadsheet if it's the spreadsheet's name.
// - no `table`: form has spreadsheet → spreadsheet cell (backward-compat);
// else first visible grid → grid cell.
if (typeof text === 'object' && text !== null && text.column != null) {
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('clickElement: no form found');
const route = await page.evaluate(resolveCellTargetScript(formNum, table));
if (route.error === 'table_not_found') {
throw new Error(`clickElement: table "${table}" not found. Available grids: ${(route.availableGrids || []).join(', ') || 'none'}`);
}
if (route.error) {
throw new Error(`clickElement: no spreadsheet or grid on form to click cell in.`);
}
if (route.kind === 'spreadsheet') {
return clickSpreadsheetCell(text, { dblclick, modifier });
}
// route.kind === 'grid'
return clickGridCell(text, {
formNum,
gridSelector: route.gridSelector,
gridName: route.gridName,
modifier, dblclick, scroll,
});
}
await dismissPendingErrors();
if (highlightMode) {
try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
}
try {
// 1. Intercept open confirmation dialog (Да/Нет/Отмена) — match button by text.
const pending = await checkForErrors();
if (pending?.confirmation) {
return await clickConfirmationButton(text);
}
// 2. Intercept open popup (from previous submenu/split-button click).
// Returns null if popup is open but `text` doesn't match — fall through.
const popupItems = await page.evaluate(readSubmenuScript());
if (Array.isArray(popupItems) && popupItems.length > 0) {
const popupResult = await tryClickPopupItem(text, popupItems);
if (popupResult) return popupResult;
}
// 3. Find a target on the current form.
let formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error(`clickElement: no form found`);
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
// Retry: if not found, a modal form may still be loading (e.g. after F4).
if (target?.error) {
for (let retry = 0; retry < 4; retry++) {
await page.waitForTimeout(500);
const newForm = await page.evaluate(detectFormScript());
if (newForm !== null && newForm !== formNum) {
formNum = newForm;
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
if (!target?.error) break;
}
}
}
// Spreadsheet fallback: search iframes for text match before giving up.
if (target?.error) {
const ssCell = await findSpreadsheetCellByText(formNum, text);
if (ssCell) {
const cx = ssCell.box.x + ssCell.box.width / 2;
const cy = ssCell.box.y + ssCell.box.height / 2;
await modifierClick(cx, cy, modifier, { dbl: !!dblclick });
await waitForStable();
return returnFormState({
clicked: { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) },
});
}
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
}
// 4. Dispatch to the right handler by target kind.
const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector };
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);
if (target.kind === 'field') return await focusFormField(target, ctx);
return await clickFormTarget(target, ctx);
} finally {
if (highlightMode) try { await unhighlight(); } catch {}
}
}
@@ -0,0 +1,97 @@
// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// pasteText() — the only path 1C respects for autocomplete and Cyrillic input.
// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME
// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't
// clobbered. Blobs are stashed on `window` to avoid CDP serialization.
import {
page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged,
} from './state.mjs';
export async function saveClipboard() {
if (!page) return;
try {
await page.evaluate(async () => {
try {
const items = await navigator.clipboard.read();
const saved = [];
for (const item of items) {
const types = {};
for (const t of item.types) types[t] = await item.getType(t);
saved.push(types);
}
window.__webTestSavedClipboard = saved;
delete window.__webTestClipboardError;
} catch (e) {
window.__webTestSavedClipboard = null;
window.__webTestClipboardError = e?.name || String(e);
}
});
} catch {
// page.evaluate itself failed (closed page, navigation in flight) — skip.
}
}
export async function restoreClipboard() {
if (!page) return;
let err = null;
try {
err = await page.evaluate(async () => {
const saved = window.__webTestSavedClipboard;
const captured = window.__webTestClipboardError || null;
delete window.__webTestSavedClipboard;
delete window.__webTestClipboardError;
try {
if (!saved || saved.length === 0) {
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
// or buffer was empty. Either way, the test's writeText already destroyed
// any prior native formats in the OS clipboard, so explicitly clear here
// to avoid leaking the test value into the user's clipboard.
await navigator.clipboard.writeText('');
return captured;
}
const items = saved.map(types => new ClipboardItem(types));
await navigator.clipboard.write(items);
return null;
} catch (e) {
return e?.name || String(e);
}
});
} catch {
return;
}
if (err && !clipboardWarnLogged) {
setClipboardWarnLogged(true);
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
}
}
/**
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
* narrow save/restore so a user's clipboard survives the test run the window
* between save and restore is microseconds.
*
* - `confirm` key (string) or sequence (array) to press after writeText.
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
* or 'Shift+F11' for the goto-link dialog.
* - `postDelay` ms to wait between confirm-press and restore, for dialogs
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
*/
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
if (!page) return;
if (preserveClipboard) await saveClipboard();
try {
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
if (Array.isArray(confirm)) {
for (const key of confirm) await page.keyboard.press(key);
} else if (confirm) {
await page.keyboard.press(confirm);
}
if (postDelay) await page.waitForTimeout(postDelay);
} finally {
if (preserveClipboard) await restoreClipboard();
}
}
@@ -0,0 +1,310 @@
// web-test core/errors v1.18 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page } from './state.mjs';
import { checkErrorsScript } from '../../dom.mjs';
import {
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
clickReportCloseButtonScript,
} from '../../dom/errors-stack.mjs';
import { waitForStable } from './wait.mjs';
/**
* Close startup modals and guide tabs.
* Strategy: Escape click default buttons close extra tabs repeat.
*/
export async function closeModals() {
for (let attempt = 0; attempt < 5; attempt++) {
// 1. Press Escape to dismiss any popup/modal
await page.keyboard.press('Escape');
await page.waitForTimeout(1000);
// 2. Try clicking default "Закрыть"/"OK" buttons
const clicked = await page.evaluate(`(() => {
const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
for (const btn of btns) {
const text = (btn.innerText?.trim() || '').toLowerCase();
if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) {
btn.click();
return text;
}
}
return null;
})()`);
if (clicked) { await page.waitForTimeout(1000); continue; }
// 3. Close extra tabs (Путеводитель etc.) via openedClose button
const tabClosed = await page.evaluate(`(() => {
const btn = document.querySelector('.openedClose');
if (btn && btn.offsetWidth > 0) { btn.click(); return true; }
return false;
})()`);
if (tabClosed) { await page.waitForTimeout(1000); continue; }
// Nothing to close — done
break;
}
}
/**
* Check for validation errors / diagnostics after an action.
* Detects: inline balloon tooltip, messages panel, modal error dialog.
* Returns { balloon, messages[], modal } or null.
*/
export async function checkForErrors() {
return await page.evaluate(checkErrorsScript());
}
/**
* Dismiss pending error modal if present (single OK button dialog).
* Called at the start of action functions so that a leftover error modal
* from a previous operation doesn't block the next action.
* Does NOT dismiss confirmations (Да/Нет require user decision).
* Returns the dismissed error object or null.
*/
export async function dismissPendingErrors() {
// Close leftover platform dialogs first (About, Support Info, Error Report)
// These block all interaction via modalSurface and are invisible to 1C form detection
try {
const pd = await detectPlatformDialogs();
if (pd.length) await closePlatformDialogs();
} catch { /* OK */ }
const err = await checkForErrors();
if (!err?.modal) return null;
try {
// Target pressDefault within the modal's form container specifically
const formNum = err.modal.formNum;
const sel = formNum != null
? `#form${formNum}_container a.press.pressDefault`
: 'a.press.pressDefault';
const btn = await page.$(sel);
if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); }
} catch { /* OK */ }
await waitForStable();
return err;
}
/**
* Detect open platform-level dialogs (About, Support Info, Error Report).
* Returns array of { type, title? } for each detected dialog, or empty array.
*/
export async function detectPlatformDialogs() {
return await page.evaluate(() => {
const result = [];
// "О программе" dialog
const about = document.getElementById('aboutContainer');
if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' });
// "Информация для технической поддержки" (inside a ps*win with errJournalInput)
const errJ = document.getElementById('errJournalInput');
if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' });
// "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer
if (!result.length) {
document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => {
if (w.offsetWidth === 0 || w.offsetHeight === 0) return;
// Skip the main app window (ps*win that contains the 1C forms)
if (w.querySelector('[id^="form"][id$="_container"]')) return;
// Check title text
const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]');
const title = titleEl?.textContent?.trim() || '';
if (title) result.push({ type: 'platformWindow', title });
});
}
return result;
});
}
/**
* Close any platform-level dialogs that may be left open (about, support info, error report).
* These are NOT 1C forms they are platform UI overlays invisible to getFormState().
* Each close is wrapped in try/catch to avoid cascading failures.
*/
export async function closePlatformDialogs() {
await page.evaluate(() => {
// "Подробный текст ошибки" OK button (inside error report detail view)
// It's a cloud window with its own OK button — look for visible pressDefault in small ps*win
const psWins = document.querySelectorAll('[id^="ps"][id$="win"]');
for (const w of psWins) {
if (w.offsetWidth === 0) continue;
// Check if this is a small dialog (error detail, about, support info)
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
if (closeBtn && closeBtn.offsetWidth > 0) {
try { closeBtn.click(); } catch {}
}
}
// "Информация для технической поддержки" — extOkBtn
const extOk = document.getElementById('extOkBtn');
if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {}
// "О программе" — aboutOkButton
const aboutOk = document.getElementById('aboutOkButton');
if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {}
});
await page.waitForTimeout(300);
}
/**
* Parse raw error stack text into structured entries.
* Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea.
* Returns { raw, timestamp?, entries: [{location, code}] }
*/
function parseErrorStack(raw) {
if (!raw) return null;
const result = { raw, entries: [] };
// Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS)
const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m);
if (tsMatch) result.timestamp = tsMatch[1];
// Extract {Module.Path(lineNum)}: code entries
const entryRe = /\{([^}]+)\}:\s*(.+)/g;
let m;
while ((m = entryRe.exec(raw)) !== null) {
result.entries.push({ location: m[1].trim(), code: m[2].trim() });
}
return result.entries.length > 0 ? result : null;
}
/**
* Fetch error call stack from the 1C platform UI.
* Uses two strategies:
* Path 1 (hasReport=true): Click OpenReport link "подробный текст ошибки" read textarea
* Path 2 (fallback): Hamburger "О программе" "Информация для техподдержки" errJournalInput
*
* Always closes the error modal and any platform dialogs it opened.
* Returns parsed stack object or null on failure.
*
* @param {number} formNum - form number of the error modal (e.g. 6 for form6_)
* @param {boolean} hasReport - true if OpenReport link is available
*/
export async function fetchErrorStack(formNum, hasReport) {
try {
// Platform exception modals are initially unstable — they redraw within ~1s.
// The initial state may lack the OpenReport link. Re-check after a short delay.
if (!hasReport) {
await page.waitForTimeout(1500);
hasReport = await page.evaluate((fn) => {
const el = document.getElementById('form' + fn + '_OpenReport#text');
return !!(el && el.offsetWidth > 2 && el.textContent.trim());
}, formNum);
}
if (hasReport) return await fetchStackViaReport(formNum);
return await fetchStackViaHamburger(formNum);
} catch {
return null;
} finally {
// Ensure all platform dialogs are closed
try { await closePlatformDialogs(); } catch {}
// Ensure the error modal itself is closed
try {
const sel = formNum != null
? `#form${formNum}_container a.press.pressDefault`
: 'a.press.pressDefault';
const btn = await page.$(sel);
if (btn) await btn.click({ force: true });
await page.waitForTimeout(300);
} catch {}
}
}
/**
* Path 1: Fetch stack via OpenReport link (for platform exceptions).
* The error modal must still be open with a visible "Сформировать отчет об ошибке" link.
*/
async function fetchStackViaReport(formNum) {
// 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks)
const coords = await page.evaluate(getOpenReportCoordsScript(formNum));
if (!coords) return null;
await page.mouse.click(coords.x, coords.y);
// 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link
let found = false;
for (let i = 0; i < 20; i++) {
await page.waitForTimeout(500);
found = await page.evaluate(isErrorDetailLinkVisibleScript());
if (found) break;
}
if (!found) return null;
// 3. Click "подробный текст ошибки"
await page.getByText('подробный текст ошибки').click();
await page.waitForTimeout(2000);
// 4. Read the textarea with detailed error text (find the largest visible textarea)
const raw = await page.evaluate(readLargestVisibleTextareaScript());
// 5. Close "Подробный текст ошибки" dialog (click its OK button)
try {
await page.evaluate(clickTopCloudOkButtonScript());
await page.waitForTimeout(300);
} catch {}
// 6. Close "Отчет об ошибке" dialog (click its × close button)
try {
await page.evaluate(clickReportCloseButtonScript());
await page.waitForTimeout(300);
} catch {}
return parseErrorStack(raw);
}
/**
* Path 2: Fetch stack via hamburger menu "О программе" "Информация для техподдержки".
* Works for all error types including simple ВызватьИсключение.
* The error modal is closed first to allow access to the hamburger menu.
*/
async function fetchStackViaHamburger(formNum) {
// 1. Close the error modal first
try {
const sel = formNum != null
? `#form${formNum}_container a.press.pressDefault`
: 'a.press.pressDefault';
const btn = await page.$(sel);
if (btn) await btn.click({ force: true });
await page.waitForTimeout(500);
} catch {}
// 2. Click hamburger menu
await page.click('#captionbarMore', { timeout: 5000 });
await page.waitForTimeout(1000);
// 3. Click "О программе..."
await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 });
await page.waitForTimeout(2000);
// 4. Click "Информация для технической поддержки"
await page.click('#aboutHyperLink', { timeout: 5000 });
// 5. Wait for errJournalInput to appear and be filled
let raw = null;
for (let i = 0; i < 20; i++) {
await page.waitForTimeout(500);
raw = await page.evaluate(() => {
const el = document.getElementById('errJournalInput');
return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null;
});
if (raw) break;
}
if (!raw) return null;
// 6. Parse first error block (most recent — before first separator)
const separator = / - - - - /;
const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw;
// Find the "Ошибки:" section
const errIdx = raw.indexOf('Ошибки:');
let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw;
// Take first block (before first separator line)
const lines = errorText.split('\n');
const firstBlockLines = [];
let inBlock = false;
for (const line of lines) {
if (separator.test(line)) {
if (inBlock) break; // end of first block
inBlock = true;
continue;
}
if (inBlock) firstBlockLines.push(line);
}
const firstBlock = firstBlockLines.join('\n').trim();
// 7. Close support info and about dialogs (done in finally via closePlatformDialogs)
return parseErrorStack(firstBlock || errorText);
}
@@ -0,0 +1,178 @@
// web-test core/helpers v1.21 — private, cross-cutting helpers used by the
// public action functions (clickElement/fillFields/selectValue/etc).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page } from './state.mjs';
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
import { getFormState } from '../forms/state.mjs';
import {
detectNewFormScript,
isInputFocusedScript,
isInputFocusedInGridScript,
findOpenPopupScript,
readEddScript,
isEddVisibleScript,
clickEddItemViaDispatchScript,
clickShowAllInEddScript,
} from '../../dom.mjs';
/**
* page.click with the standard "intercepts pointer events" retry ladder:
* normal force Escape (+ optional dismissPendingErrors) normal.
* Mirrors the three hand-written copies in fillReferenceField, clickElement
* and the DLB branch of selectValue.
*
* @param {string} selector
* @param {object} [opts]
* @param {number} [opts.timeout] passed through to page.click
* @param {boolean} [opts.dismissErrors=false] call dismissPendingErrors()
* before pressing Escape on the second retry (used in fillReferenceField).
*/
export async function safeClick(selector, { timeout, dismissErrors = false } = {}) {
const baseOpts = timeout != null ? { timeout } : {};
try {
await page.click(selector, baseOpts);
} catch (e) {
if (!e.message.includes('intercepts pointer events')) throw e;
try {
await page.click(selector, { ...baseOpts, force: true });
} catch (e2) {
if (!e2.message.includes('intercepts pointer events')) throw e2;
if (dismissErrors) await dismissPendingErrors();
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
await page.click(selector, baseOpts);
}
}
}
/**
* Find a form field's input element id by name. Tries `form{N}_{name}` first,
* then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the
* element id or null. Used in selectValue's clear/composite-type/F4 fallback
* branches.
*
* @param {number} formNum
* @param {string} fieldName
* @returns {Promise<string|null>}
*/
export async function findFieldInputId(formNum, fieldName) {
return await page.evaluate(`(() => {
const p = 'form${formNum}_';
const name = ${JSON.stringify(fieldName)};
const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
return el ? el.id : null;
})()`);
}
/**
* Detect a new form opened above the given `prevFormNum`. Two modes:
* `{ strict: true }` only counts visible interactive elements
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
* default (broad) any element with `id^=form{N}_` that's visible
* in either dimension; also finds type-dialogs whose a.press buttons
* have empty IDs. Used by selectValue / fillTableRow.
*
* @param {number} prevFormNum
* @param {object} [opts]
* @param {boolean} [opts.strict=false]
* @returns {Promise<number|null>} new form number or null
*/
export async function detectNewForm(prevFormNum, { strict = false } = {}) {
return page.evaluate(detectNewFormScript(prevFormNum, { strict }));
}
/**
* Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)?
*
* @param {object} [opts]
* @param {boolean} [opts.allowTextarea=false]
*/
export async function isInputFocused({ allowTextarea = false } = {}) {
return page.evaluate(isInputFocusedScript({ allowTextarea }));
}
/**
* Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`?
* Used to verify grid edit-mode. Pass `{ gridSelector }` to scope the check
* to a specific grid (when a form has multiple grids).
*/
export async function isInputFocusedInGrid({ gridSelector } = {}) {
return page.evaluate(isInputFocusedInGridScript(gridSelector));
}
/**
* Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`)
* popup visible? Returns `'calculator' | 'calendar' | null`.
*/
export async function findOpenPopup() {
return page.evaluate(findOpenPopupScript());
}
/**
* Read the `#editDropDown` autocomplete popup. Returns whether it's visible
* and, when visible, an array of `.eddText` items with display name and
* center coordinates (suitable for page.mouse.click).
*
* @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>}
*/
export async function readEdd() {
return page.evaluate(readEddScript());
}
/**
* Thin wrapper: is the EDD popup currently visible?
* Lighter than `readEdd` when only presence matters.
*/
export async function isEddVisible() {
return page.evaluate(isEddVisibleScript());
}
/**
* Click an EDD item by name via dispatchEvent (bypasses div.surface overlays).
* Returns the clicked item's innerText, or `null` if no match.
*/
export async function clickEddItemViaDispatch(itemName) {
return page.evaluate(clickEddItemViaDispatchScript(itemName));
}
/**
* Click the "Показать все" / "Show all" link in the EDD footer.
* Returns boolean.
*/
export async function clickShowAllInEdd() {
return page.evaluate(clickShowAllInEddScript());
}
/**
* Standard "tail" of action functions: fetch current form state, attach
* caller-specified extras (e.g. `{ clicked: {...} }`) and the result of
* `checkForErrors()` if any. Returns the flat state object.
*
* Unifies ~15 hand-written copies in clickElement, selectValue, closeForm,
* navigation functions, etc. Also closes R1/R2/R3 from the refactor plan
* any caller using this helper gets `state.errors` for free.
*
* @param {object} [extras] merged into the state object via Object.assign.
* @returns {Promise<object>} form state (flat) with optional `errors`.
*/
export async function returnFormState(extras = {}) {
const state = await getFormState();
Object.assign(state, extras);
const err = await checkForErrors();
if (err) state.errors = err;
return state;
}
/**
* Mouse click at (x, y) with an optional modifier key held down for the duration.
* Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select).
* Pass `{ dbl: true }` for double-click.
*/
export async function modifierClick(x, y, modifier, { dbl = false } = {}) {
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
if (modKey) await page.keyboard.down(modKey);
if (dbl) await page.mouse.dblclick(x, y);
else await page.mouse.click(x, y);
if (modKey) await page.keyboard.up(modKey);
}
@@ -0,0 +1,47 @@
// web-test core/scroll-horiz v1.0 — horizontal scroll loop helper for grids and spreadsheets.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// 1С scrolls horizontally by shifting absolute X coordinates of cells (not via
// scrollLeft). The only reliable way to drive this from outside is to press
// ArrowRight / ArrowLeft on a focused cell. Both SpreadsheetDocument and form
// grids share this mechanic, so the loop body is identical: press an arrow,
// wait, check visibility, bail when the cell stops moving (lost focus / hit edge).
//
// Callers handle their own focus setup (clicking a visible cell to put keyboard
// focus on the grid/spreadsheet), direction selection, and visibility queries.
/**
* Press {direction} key in a loop until the target cell is fully visible or
* progress stalls.
*
* @param {object} opts
* @param {import('playwright').Page} opts.page
* @param {'ArrowRight'|'ArrowLeft'} opts.direction
* @param {() => Promise<boolean>} opts.isFullyVisible true when target inside viewport
* @param {() => Promise<number|null>} opts.getCenterX current target center X (page coords); null if cell vanished
* @param {number} [opts.maxPresses=100]
* @param {number} [opts.staleMax=5] bail when center hasn't moved this many presses in a row
* @param {number} [opts.delayMs=50] wait after each key press
* @param {number} [opts.finalDelayMs=200] wait after the loop completes
*/
export async function scrollHorizontallyByKey({
page, direction,
isFullyVisible, getCenterX,
maxPresses = 100, staleMax = 5,
delayMs = 50, finalDelayMs = 200,
}) {
let prevCx = await getCenterX();
if (prevCx == null) return;
let stale = 0;
for (let i = 0; i < maxPresses; i++) {
await page.keyboard.press(direction);
await page.waitForTimeout(delayMs);
if (await isFullyVisible()) break;
const cx = await getCenterX();
if (cx == null) break;
if (Math.abs(cx - prevCx) >= 1) stale = 0;
else { stale++; if (stale >= staleMax) break; }
prevCx = cx;
}
await page.waitForTimeout(finalDelayMs);
}
@@ -0,0 +1,404 @@
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { chromium } from 'playwright';
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { join as pathJoin } from 'path';
import { tmpdir } from 'os';
import {
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
contexts, activeContextName, activeMode, persistentUserDataDir,
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
setActiveContextName, setActiveMode, setPersistentUserDataDir,
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
} from './state.mjs';
import { closeModals } from './errors.mjs';
import { stopRecording } from '../recording/capture.mjs';
import { getPageState } from '../nav/navigation.mjs';
/**
* Find the 1C browser extension in Chrome/Edge user profiles.
* Returns the path to the latest version, or null if not found.
* Can be overridden via extensionPath in .v8-project.json.
*/
function findExtension(overridePath) {
if (overridePath) {
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
return null;
}
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) return null;
const browsers = [
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
];
for (const userData of browsers) {
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
let profiles;
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
for (const profile of profiles) {
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
let versions;
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
if (versions.length > 0) {
const best = pathJoin(extDir, versions[versions.length - 1]);
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
}
}
}
return null;
}
/* isConnected moved to core/state.mjs */
/**
* Open browser and navigate to 1C web client URL.
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
*/
export async function connect(url, { extensionPath } = {}) {
if (isConnected()) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
} else {
const extPath = findExtension(extensionPath);
if (extPath) {
// Launch with 1C browser extension via persistent context
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
mkdirSync(persistentUserDataDir, { recursive: true });
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
headless: false,
args: [
'--start-maximized',
'--disable-extensions-except=' + extPath,
'--load-extension=' + extPath,
],
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
});
setBrowser(context); // persistent context IS the browser
setPage(context.pages()[0] || await context.newPage());
} else {
// Fallback: launch without extension
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
const context = await browser.newContext({
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
});
setPage(await context.newPage());
}
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
page.on('dialog', dialog => dialog.accept().catch(() => {}));
// Capture seanceId from network requests for graceful logout
setSessionPrefix(null);
setSeanceId(null);
page.on('request', req => {
if (seanceId) return;
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); }
});
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
}
// Wait for 1C to initialize — detect by section panel appearance
try {
await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
} catch {
// Fallback: wait fixed time if selector doesn't appear (e.g. login page)
await page.waitForTimeout(5000);
}
// Try to close startup modals (Путеводитель etc.)
await closeModals();
return await getPageState();
}
/**
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
* Silent if page is closed or session info missing, just returns.
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
*/
async function logoutSlot(slot, waitMs = 500) {
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
try {
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
await slot.page.evaluate(async (url) => {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
}, logoutUrl);
await slot.page.waitForTimeout(waitMs);
} catch {}
}
/**
* Gracefully terminate the 1C session and close the browser.
* Sends POST /e1cib/logout to release the license before closing.
*/
export async function disconnect() {
// Multi-context path: stop recording + logout each slot before closing browser
if (contexts.size > 0) {
saveActiveSlot();
// Recorder is global — one stop covers all contexts
if (recorder) {
try { await stopRecording(); } catch {}
}
for (const [, slot] of contexts.entries()) {
await logoutSlot(slot);
}
contexts.clear();
setActiveContextName(null);
setActiveMode(null);
}
// Single-session path (connect): auto-stop recording if active
if (recorder) {
try { await stopRecording(); } catch {}
}
if (browser) {
// Graceful logout — release the 1C license (single-session connect path)
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
await browser.close().catch(() => {});
setBrowser(null);
setPage(null);
setSessionPrefix(null);
setSeanceId(null);
// Clean up persistent user data dir
if (persistentUserDataDir) {
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
setPersistentUserDataDir(null);
}
}
}
/**
* Attach to a running browser server via CDP WebSocket.
* Sets module state so all functions (getFormState, clickElement, etc.) work.
*/
export async function attach(wsEndpoint, session = {}) {
if (isConnected()) return;
setBrowser(await chromium.connect(wsEndpoint));
const ctx = browser.contexts()[0];
setPage(ctx?.pages()[0]);
if (!page) throw new Error('No page found in browser');
setSessionPrefix(session.sessionPrefix || null);
setSeanceId(session.seanceId || null);
}
/**
* Detach from browser without closing it.
* Returns session state for persistence.
*/
export function detach() {
const session = { sessionPrefix, seanceId };
setBrowser(null);
setPage(null);
setSessionPrefix(null);
setSeanceId(null);
return session;
}
/** Get current session state (for saving between reconnections). */
export function getSession() {
return { sessionPrefix, seanceId };
}
// ============================================================
// Multi-context support (used by run.mjs cmdTest only)
// ============================================================
/**
* Save current module-level state into the active slot before switching.
* No-op if no active slot.
*/
function saveActiveSlot() {
if (!activeContextName) return;
const slot = contexts.get(activeContextName);
if (!slot) return;
slot.page = page;
slot.sessionPrefix = sessionPrefix;
slot.seanceId = seanceId;
slot.highlightMode = highlightMode;
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
// mirrored per-slot. A multi-context recording produces one continuous output file —
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
}
/** Load a slot's state into module-level vars and mark it active. */
function activateSlot(name) {
const slot = contexts.get(name);
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
setPage(slot.page);
setSessionPrefix(slot.sessionPrefix);
setSeanceId(slot.seanceId);
setHighlightMode(slot.highlightMode || false);
setActiveContextName(name);
}
/** Attach 1C session listeners to a page, writing into the given slot. */
function attachSessionListeners(pg, slot, name) {
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
pg.on('request', req => {
if (slot.seanceId) return;
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
if (m) {
slot.sessionPrefix = m[1];
slot.seanceId = m[2];
if (activeContextName === name) {
setSessionPrefix(m[1]);
setSeanceId(m[2]);
}
}
});
}
/**
* Create (or navigate) a named browser context.
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
* subsequent calls can create additional isolated BrowserContexts in the same process.
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
* persistent profile.
*
* Use this from run.mjs cmdTest only exec/run/start use connect() and stay on the
* legacy persistent-context path.
*/
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
if (contexts.has(name)) {
await setActiveContext(name);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
catch { await page.waitForTimeout(5000); }
await closeModals();
return await getPageState();
}
if (!['tab', 'window'].includes(isolation)) {
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
}
if (activeMode && activeMode !== isolation) {
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
}
// First context: launch browser. Subsequent: reuse existing.
let isFirstContext = !browser;
if (isFirstContext) {
const extPath = findExtension(extensionPath);
const launchArgs = ['--start-maximized'];
if (extPath) {
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
}
if (isolation === 'tab') {
// Persistent context: extension loads reliably, one window with tabs per context
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
mkdirSync(persistentUserDataDir, { recursive: true });
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
headless: false,
args: launchArgs,
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
}));
} else {
// Window mode: separate BrowserContext per slot, full cookie isolation
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
}
setActiveMode(isolation);
}
// Save current active before switching
saveActiveSlot();
// Create slot — page differs by mode
let newCtx, newPage;
if (activeMode === 'tab') {
// Reuse the persistent context for all slots; each slot gets its own page (tab)
newCtx = browser;
if (isFirstContext) {
newPage = browser.pages()[0] || await browser.newPage();
} else {
newPage = await browser.newPage();
}
} else {
// Window mode: each slot owns its BrowserContext + page
newCtx = await browser.newContext({
viewport: null,
permissions: ['clipboard-read', 'clipboard-write'],
});
newPage = await newCtx.newPage();
}
const slot = {
context: newCtx,
page: newPage,
sessionPrefix: null,
seanceId: null,
highlightMode: false,
};
contexts.set(name, slot);
attachSessionListeners(newPage, slot, name);
activateSlot(name);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
catch { await page.waitForTimeout(5000); }
await closeModals();
return await getPageState();
}
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
export async function setActiveContext(name) {
if (activeContextName === name) return;
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
// If a recording is active, flush the outgoing page's last frame so the gap is filled
// up to the moment of the switch (avoids a "jump" in video time).
if (recorder && recorder._flushFrames) recorder._flushFrames();
saveActiveSlot();
activateSlot(name);
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
// re-attach its screencast to the newly active page.
if (recorder && recorder._attachPage) {
await recorder._attachPage(page);
}
}
export function listContexts() {
return [...contexts.keys()];
}
export function getActiveContext() {
return activeContextName;
}
export function hasContext(name) {
return contexts.has(name);
}
/**
* Close a named context: logout, close its page (tab mode) or BrowserContext
* (window mode), remove from registry. Cannot close the currently active
* context caller must setActiveContext to another first. This keeps the
* recorder/page invariants simple: recorder is always attached to the
* active slot, which closeContext never touches.
*
* @throws if name is not registered or equals the active context.
*/
export async function closeContext(name) {
if (!contexts.has(name)) {
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
}
if (name === activeContextName) {
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
}
const slot = contexts.get(name);
await logoutSlot(slot);
if (activeMode === 'tab') {
try { await slot.page.close(); } catch {}
} else {
try { await slot.context.close(); } catch {}
}
contexts.delete(name);
}
@@ -0,0 +1,113 @@
// web-test core/state v1.17 — module-level state for the web-test engine.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Holds the single browser/page/recorder slot plus the multi-context registry,
// constants, and small state-only utilities (ensureConnected, getPage,
// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
// for live read access from consumer modules; writes go through setters so
// imported bindings stay read-only at the import site.
import { dirname, resolve as pathResolve } from 'path';
import { fileURLToPath } from 'url';
// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs
const __fn_state = fileURLToPath(import.meta.url);
export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..');
/** Resolve a user-provided path relative to the project root (not cwd). */
export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
// ──────────────────────────────────────────────────────────────────────────
// Mutable single-session state. Importers read via the live binding; writes
// must go through the corresponding setter (ESM imports are read-only).
// ──────────────────────────────────────────────────────────────────────────
export let browser = null;
export let page = null;
export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
export let seanceId = null;
export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
export let lastCaptions = []; // captions from the last completed recording (for addNarration)
export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
export let highlightMode = false;
export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
// Clipboard preservation: save full clipboard contents (all MIME types) right
// before each writeText+Ctrl+V pair, restore right after. Toggled via
// setPreserveClipboard() from run.mjs.
export let preserveClipboard = true;
export let clipboardWarnLogged = false;
export const setBrowser = (v) => { browser = v; };
export const setPage = (v) => { page = v; };
export const setSessionPrefix = (v) => { sessionPrefix = v; };
export const setSeanceId = (v) => { seanceId = v; };
export const setRecorder = (v) => { recorder = v; };
export const setLastCaptions = (v) => { lastCaptions = v; };
export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
export const setHighlightMode = (v) => { highlightMode = !!v; };
export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
// ──────────────────────────────────────────────────────────────────────────
// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
// Populated by createContext(); module-level vars above mirror the active
// slot. connect() does NOT use this Map — it preserves legacy single-session
// behavior for exec/run/start.
// ──────────────────────────────────────────────────────────────────────────
export const contexts = new Map();
export let activeContextName = null;
// Isolation mode for the current cmdTest session — set by the first
// createContext call. 'tab': all contexts share one persistent context
// (one window, multiple tabs, extension loads reliably). 'window': each
// context gets its own BrowserContext (separate window per context, full
// cookie isolation, extension may not load).
export let activeMode = null;
export const setActiveContextName = (v) => { activeContextName = v; };
export const setActiveMode = (v) => { activeMode = v; };
// ──────────────────────────────────────────────────────────────────────────
// Constants.
// ──────────────────────────────────────────────────────────────────────────
export const LOAD_TIMEOUT = 60000;
export const INIT_TIMEOUT = 60000;
export const ACTION_WAIT = 2000; // fallback minimum wait
export const MAX_WAIT = 10000; // max wait for stability
export const POLL_INTERVAL = 200; // polling interval
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
// ──────────────────────────────────────────────────────────────────────────
// Utilities that only depend on state.
// ──────────────────────────────────────────────────────────────────────────
/** Normalize ё→е and  →space for fuzzy matching. */
export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
/** Check if browser is connected and page is usable. */
export function isConnected() {
if (!browser || !page || page.isClosed()) return false;
// launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
if (typeof browser.isConnected === 'function') return browser.isConnected();
// For persistent context, check via context's browser()
return browser.browser()?.isConnected() ?? false;
}
export function ensureConnected() {
if (!isConnected()) {
throw new Error('Browser not connected. Call web_connect first.');
}
}
/** Get the raw Playwright page object (for advanced scripting in skill mode). */
export function getPage() {
ensureConnected();
return page;
}
@@ -0,0 +1,123 @@
// web-test core/wait v1.17 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs';
import { detectFormScript } from '../../dom.mjs';
/**
* Smart wait: poll until DOM is stable and no loading indicators are visible.
* Checks: form number change, loading indicators, DOM stability.
* @param {number|null} previousFormNum form number before the action (null = don't check)
*/
export async function waitForStable(previousFormNum = null) {
let stableCount = 0;
let lastSnapshot = '';
const start = Date.now();
while (Date.now() - start < MAX_WAIT) {
await page.waitForTimeout(POLL_INTERVAL);
// Check for loading indicators
const status = await page.evaluate(`(() => {
const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
const isLoading = loading && loading.offsetWidth > 0;
const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
return { isLoading, formCount };
})()`);
if (status.isLoading) {
stableCount = 0;
continue;
}
// Check DOM stability by comparing element count snapshot
const snapshot = String(status.formCount);
if (snapshot === lastSnapshot) {
stableCount++;
} else {
stableCount = 0;
lastSnapshot = snapshot;
}
// If form was expected to change, ensure it did
if (previousFormNum !== null && stableCount === 1) {
const currentForm = await page.evaluate(detectFormScript());
if (currentForm !== previousFormNum) {
// Form changed — still wait for stability
}
}
if (stableCount >= STABLE_CYCLES) return;
}
// Fallback: max wait reached
}
/**
* Start monitoring network activity via CDP.
* Must be called BEFORE the click so it captures all server requests.
* Returns a monitor object with waitDone() and cleanup() methods.
*/
export async function startNetworkMonitor() {
const client = await page.context().newCDPSession(page);
await client.send('Network.enable');
let pending = 0;
let total = 0;
let lastZeroTime = null;
const DEBOUNCE = 300;
client.on('Network.requestWillBeSent', () => {
pending++;
total++;
lastZeroTime = null;
});
client.on('Network.loadingFinished', () => {
if (--pending === 0) lastZeroTime = Date.now();
});
client.on('Network.loadingFailed', () => {
if (--pending === 0) lastZeroTime = Date.now();
});
return {
/** Wait until all network requests complete (300ms debounce) or UI element appears. */
async waitDone(timeout = 10000) {
const start = Date.now();
while (Date.now() - start < timeout) {
await page.waitForTimeout(50);
// Check for UI elements (modal, balloon, confirm)
const ui = await page.evaluate(`(() => {
const modal = document.querySelector('#modalSurface:not([style*="display: none"])');
const balloon = document.querySelector('.balloon');
const confirm = document.querySelector('.confirm');
return !!(modal || balloon || confirm);
})()`);
if (ui) return;
// CDP debounce: pending===0 held for DEBOUNCE ms
if (total > 0 && pending === 0 && lastZeroTime !== null) {
if (Date.now() - lastZeroTime >= DEBOUNCE) return;
}
}
},
/** Detach CDP session. Always call this when done. */
async cleanup() {
await client.send('Network.disable').catch(() => {});
await client.detach().catch(() => {});
}
};
}
/**
* Poll until a JS expression returns truthy, or timeout (ms) expires.
* Resolves early typically within 100-300ms instead of fixed delays.
*/
export async function waitForCondition(evalScript, timeout = 2000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const result = await page.evaluate(evalScript);
if (result) return result;
await page.waitForTimeout(100);
}
return null;
}
@@ -0,0 +1,122 @@
// web-test forms/click-form v1.1 — click handler for form-element targets: button, tab, submenu, link, field-focus.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Called by core/click.mjs dispatcher after target is found.
// Owns the CDP network-monitor lifecycle for button clicks (server roundtrip waits),
// post-click submenu detection (split buttons like "Создать на основании"),
// and confirmation hint propagation in the final state.
import { page, ACTION_WAIT } from '../core/state.mjs';
import {
detectFormScript, readSubmenuScript,
} from '../../dom.mjs';
import { checkForErrors } from '../core/errors.mjs';
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
import { safeClick, returnFormState, isInputFocused } from '../core/helpers.mjs';
/**
* Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}.
* Handles three special concerns:
* 1. **netMonitor** for `kind: 'button'` captures CDP requests started by the click
* so we can wait for them (when the form doesn't change) before stabilising.
* 2. **Submenu detection** both pre-click (`kind: 'submenu'` already known) and
* post-click (split buttons like "Создать на основании" which open a popup).
* Returns `submenu[]` items as a hint for the caller.
* 3. **Confirmation propagation** if a confirmation dialog opens as a result of the
* click, surface `confirmation` and `hint` fields on the returned state so the
* caller can react with Да/Нет/Отмена on the next call.
*/
export async function clickFormTarget(target, ctx) {
const { formNum, timeout } = ctx;
let netMonitor = null;
try {
// CDP network monitor BEFORE the click for buttons — captures all server requests
// triggered by the click so we can wait for them after.
if (target.kind === 'button') {
try { netMonitor = await startNetworkMonitor(); } catch {}
}
// Tabs without ID — use coordinate click to avoid global [data-content] ambiguity
if (target.kind === 'tab' && !target.id && target.x && target.y) {
await page.mouse.click(target.x, target.y);
} else {
const selector = `[id="${target.id}"]`;
// Use Playwright click for proper mousedown/mouseup events
await safeClick(selector, { timeout: 5000 });
}
// Pre-known submenu button — read popup items and return them as hints
if (target.kind === 'submenu') {
await page.waitForTimeout(ACTION_WAIT);
const submenuItems = await page.evaluate(readSubmenuScript());
const extras = { clicked: { kind: 'submenu', name: target.name } };
if (Array.isArray(submenuItems)) {
extras.submenu = submenuItems.map(i => i.name);
extras.hint = 'Call web_click again with a submenu item name to select it';
}
return returnFormState(extras);
}
await waitForStable(formNum);
// Check if the click opened a popup/submenu (split buttons like "Создать на основании")
const openedPopup = await page.evaluate(readSubmenuScript());
if (Array.isArray(openedPopup) && openedPopup.length > 0) {
return returnFormState({
clicked: { kind: 'submenu', name: target.name },
submenu: openedPopup.map(i => i.name),
hint: 'Call web_click again with a submenu item name to select it',
});
}
// For buttons that trigger server-side operations (post, write, etc.),
// the DOM may stabilise BEFORE the server response arrives.
// The CDP monitor (started before click) lets us wait for all in-flight requests
// to complete (300ms debounce) or for a modal/balloon/confirm to appear.
// Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected.
if (target.kind === 'button') {
const postForm = await page.evaluate(detectFormScript());
if (postForm === formNum) {
const inGridEdit = await page.evaluate(`(() => {
const f = document.activeElement;
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
return false;
})()`);
if (!inGridEdit && netMonitor) {
await netMonitor.waitDone(timeout);
await waitForStable();
}
}
}
// Build final state with confirmation propagation
// (the one custom branch deliberately skipped by Phase 2 — surfaces confirmation
// + hint when a save/delete dialog opened as a result of the click).
const extras = { clicked: { kind: target.kind, name: target.name } };
const err = await checkForErrors();
if (err?.confirmation) {
extras.confirmation = err.confirmation;
extras.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
}
return returnFormState(extras);
} finally {
if (netMonitor) try { await netMonitor.cleanup(); } catch {}
}
}
/**
* Focus a form input field (last-resort target kind: 'field') by clicking the input itself
* does NOT change its value. Lets the caller then drive focus-dependent shortcuts
* (F4 selection form, Shift+F4 clear, etc.) via getPage().keyboard.
* Returns flat form state with `focused: { field, id, ok }`; `ok` reflects whether the
* input actually received focus (false for disabled/readonly fields). Never throws on ok=false.
*/
export async function focusFormField(target, ctx) {
const selector = `[id="${target.id}"]`;
await safeClick(selector, { timeout: 5000 });
await waitForStable(ctx.formNum);
const ok = await isInputFocused({ allowTextarea: true });
return returnFormState({ focused: { field: target.name, id: target.id, ok } });
}
@@ -0,0 +1,90 @@
// web-test forms/click-popup v1.0 — click handlers for in-form popups: confirmation dialogs and open submenus.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Both handlers run BEFORE clickElement's regular target-finding flow:
// - clickConfirmationButton intercepts when a pending confirmation dialog is open
// - tryClickPopupItem intercepts when a submenu/popup is open from a previous click
import { page, ACTION_WAIT, normYo } from '../core/state.mjs';
import { readSubmenuScript } from '../../dom.mjs';
import { waitForStable } from '../core/wait.mjs';
import { returnFormState } from '../core/helpers.mjs';
/**
* Click a button in the currently-open confirmation dialog (Да/Нет/Отмена, etc).
* Caller is responsible for verifying that a confirmation is actually pending
* (via checkForErrors().confirmation) before invoking this handler.
*
* Throws if no button matching `text` is found in the dialog.
*/
export async function clickConfirmationButton(text) {
const btnResult = await page.evaluate(`(() => {
const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || '';
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
const target = ny(${JSON.stringify(text.toLowerCase())});
const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0);
let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target);
if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target));
if (best) {
const r = best.getBoundingClientRect();
return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
}
return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) };
})()`);
if (btnResult?.error) {
throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`);
}
await page.mouse.click(btnResult.x, btnResult.y);
await waitForStable();
return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } });
}
/**
* Try to click an item inside an already-open submenu/popup.
*
* Returns a form-state result on match (kind: 'popupItem' or 'submenuArrow'),
* or `null` if the requested text doesn't match any visible popup item in
* which case the caller should fall through to regular form-element finding.
*
* @param {string} text fuzzy-matched against item labels (NBSP/ё-normalised)
* @param {Array} popupItems items already read via readSubmenuScript()
*/
export async function tryClickPopupItem(text, popupItems) {
const target = normYo(text.toLowerCase());
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!found) return null;
// submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu
if (found.kind === 'submenuArrow') {
// page.hover(selector) is more reliable than page.mouse.move(x,y) —
// some submenu groups don't expand with plain mouse.move
if (found.id) {
await page.hover(`[id="${found.id}"]`);
} else {
await page.mouse.move(found.x, found.y);
}
await page.waitForTimeout(ACTION_WAIT);
const nestedItems = await page.evaluate(readSubmenuScript());
const extras = { clicked: { kind: 'submenuArrow', name: found.name } };
if (Array.isArray(nestedItems)) {
extras.submenu = nestedItems.map(i => i.name);
extras.hint = 'Call web_click again with a submenu item name to select it';
}
return returnFormState(extras);
}
// Regular submenu/dropdown items — trusted events required.
// Use mouse.click(x,y) when in viewport; use :visible selector for clipped items
// (same ID can exist hidden in parent cloud AND visible in nested cloud).
const vpHeight = await page.evaluate('window.innerHeight');
if (found.x && found.y && found.y > 0 && found.y < vpHeight) {
await page.mouse.click(found.x, found.y);
} else if (found.id) {
await page.click(`[id="${found.id}"]:visible`);
} else if (found.x && found.y) {
await page.mouse.click(found.x, found.y);
}
await waitForStable();
return returnFormState({ clicked: { kind: 'popupItem', name: found.name } });
}
@@ -0,0 +1,56 @@
// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, recorder, ensureConnected } from '../core/state.mjs';
import { detectFormScript } from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
import { waitForStable } from '../core/wait.mjs';
import { returnFormState } from '../core/helpers.mjs';
import { getFormState } from './state.mjs';
/**
* Close the current form/dialog via Escape.
* @param {Object} [opts]
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
* true click "Да" (save and close)
* false click "Нет" (discard and close)
* undefined return confirmation as hint for caller to decide
*/
export async function closeForm({ save } = {}) {
ensureConnected();
await dismissPendingErrors();
// If platform dialogs are open, close them instead of pressing Escape
const pd = await detectPlatformDialogs();
if (pd.length) {
await closePlatformDialogs();
await page.waitForTimeout(300);
return returnFormState({ closed: true, closedPlatformDialogs: pd });
}
const beforeForm = await page.evaluate(detectFormScript());
await page.keyboard.press('Escape');
await waitForStable(beforeForm);
const state = await getFormState();
const err = await checkForErrors();
if (err?.confirmation) {
if (save === true || save === false) {
const label = save ? 'Да' : 'Нет';
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
const btns = await page.$$(btnSel);
for (const b of btns) {
const txt = (await b.textContent()).trim();
if (txt === label) {
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
await b.click({ force: true });
await waitForStable(beforeForm);
break;
}
}
const afterForm = await page.evaluate(detectFormScript());
return returnFormState({ closed: afterForm !== beforeForm });
}
state.confirmation = err.confirmation;
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
return state;
}
return returnFormState({ closed: state.form !== beforeForm });
}
@@ -0,0 +1,147 @@
// web-test forms/fill v1.19 — Fill form fields by name (text/checkbox/date/number/dropdown/reference). Delegates references to selectValue / fillReferenceField.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, ACTION_WAIT, highlightMode, normYo,
} from '../core/state.mjs';
import {
detectFormScript, resolveFieldsScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import {
fillReferenceField, selectValue, pickFromSelectionForm,
isTypeDialog, pickFromTypeDialog,
} from './select-value.mjs';
import { pasteText } from '../core/clipboard.mjs';
import { returnFormState } from '../core/helpers.mjs';
/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
export async function fillFields(fields) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('fillFields: no form found');
// Resolve field names to element IDs
const resolved = await page.evaluate(resolveFieldsScript(formNum, fields));
const results = [];
for (const r of resolved) {
if (r.error) {
results.push(r);
continue;
}
// Auto-highlight the field input before filling
if (highlightMode && r.inputId) {
try {
await page.evaluate(({ id }) => {
const target = document.getElementById(id);
if (!target) return;
let div = document.getElementById('__web_test_highlight');
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
const r = target.getBoundingClientRect();
div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
}, { id: r.inputId });
await page.waitForTimeout(500);
await unhighlight();
} catch {}
}
try {
// Auto-enable DCS checkbox if resolved via label
if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
await waitForStable();
}
const selector = `[id="${r.inputId}"]`;
// Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
const rawValue = fields[r.field];
const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
if (isEmpty && !r.isCheckbox && !r.isRadio) {
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
results.push({ field: r.field, ok: true, value: '', method: 'clear' });
continue;
}
if (r.isCheckbox) {
// Checkbox: compare desired with current, toggle if mismatch
const desired = String(fields[r.field]).toLowerCase();
const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired);
if (wantChecked !== r.checked) {
await page.click(selector);
await waitForStable();
}
results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' });
} else if (r.isRadio) {
// Radio button: find option by label (fuzzy match) and click it
const desired = normYo(String(fields[r.field]).toLowerCase());
const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
|| r.options.find(o => normYo(o.label.toLowerCase()).includes(desired));
if (opt) {
// Option 0 = base element (no suffix), options 1+ = #N#radio
const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`;
await page.click(`[id="${radioId}"]`);
await waitForStable();
results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' });
} else {
results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) });
}
} else if (r.hasSelect) {
// Combobox/reference with DLB: DLB-first, then paste fallback
const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
results.push(refResult);
} else if (r.hasPick && (r.isDate || r.isCalc)) {
// Date/time (calendar CB) or numeric (calculator CB) field — use paste:
// the pick button is a calendar/calculator widget, not a selection form.
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(fields[r.field]);
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
} else if (r.hasPick) {
// Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
const svResult = await selectValue(r.field, String(fields[r.field]));
if (svResult?.error) {
results.push({ field: r.field, error: svResult.error, message: svResult.message });
} else {
results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
}
} else {
// Plain field: clipboard paste + Tab to commit
// page.fill() sets DOM value but doesn't trigger 1C input events;
// clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly.
await page.click(selector);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(fields[r.field]);
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
}
} catch (e) {
results.push({ field: r.field, error: e.message });
}
if (highlightMode) try { await unhighlight(); } catch {}
}
const failed = results.filter(r => r.error);
if (failed.length > 0) {
const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
}
return returnFormState({ filled: results });
}
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
export async function fillField(name, value) {
return fillFields({ [name]: value });
}
@@ -0,0 +1,849 @@
// web-test forms/select-value v1.24 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
} from '../core/state.mjs';
import {
detectFormScript, findFieldButtonScript, resolveFieldsScript,
readSubmenuScript, checkErrorsScript,
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript,
findChildFormByButtonScript, readTypeDialogVisibleRowsScript,
} from '../../dom.mjs';
import { scanGridRowsScript } from '../../dom/grid.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import {
safeClick, findFieldInputId, readEdd,
detectNewForm as helperDetectNewForm,
clickEddItemViaDispatch, clickShowAllInEdd, returnFormState,
} from '../core/helpers.mjs';
import { pasteText } from '../core/clipboard.mjs';
import { getFormState } from './state.mjs';
import { filterList } from '../table/filter.mjs';
/**
* Scan visible grid rows for a text match (exact startsWith includes).
* Returns center coords of the matched row, or null if not found.
* When searchLower is empty, returns coords of the first row (fallback).
*/
async function scanGridRows(formNum, searchLower) {
return page.evaluate(scanGridRowsScript(formNum, searchLower));
}
/**
* Select a row in a selection form via click + Enter, verify it closed.
* Uses click + Enter instead of dblclick because dblclick toggles
* expand/collapse in tree-style selection forms.
* Returns { field, ok: true, method: 'form' } on success,
* or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row).
*/
async function dblclickAndVerify(coords, selFormNum, fieldName) {
// Click to highlight the row, then Enter to confirm selection.
// This works for both flat grids and tree forms (dblclick would
// toggle expand/collapse on tree group rows).
await page.mouse.click(coords.x, coords.y);
await page.waitForTimeout(200);
await page.keyboard.press('Enter');
await waitForStable(selFormNum);
// Verify selection form closed
const stillOpen = await page.evaluate(isFormVisibleScript(selFormNum));
if (stillOpen) {
// Enter didn't select — item is likely a non-selectable group.
// Don't Escape here — let the caller decide (may want to try another row).
return { field: fieldName, ok: false, reason: 'still_open' };
}
// Check for 1C error modals after selection
const err = await page.evaluate(checkErrorsScript());
if (err?.modal) {
try {
const btn = await page.$('a.press.pressDefault');
if (btn) { await btn.click(); await page.waitForTimeout(500); }
} catch { /* OK */ }
}
return { field: fieldName, ok: true, method: 'form' };
}
/**
* Inline advanced search on a selection form via Alt+F.
* Does NOT click any column FieldSelector auto-populates with main representation.
* Switches to "по части строки" (CompareType#1) to avoid composite type issues.
* Does not throw returns silently on failure.
*/
async function advancedSearchInline(formNum, text) {
try {
// 1. Open advanced search via Alt+F
await page.keyboard.press('Alt+f');
await page.waitForTimeout(2000);
const dialogForm = await page.evaluate(detectFormScript());
if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog
// 2. Switch to "по части строки" (CompareType#1)
const radioClicked = await page.evaluate(findCompareTypeRadioScript(dialogForm, 1));
if (radioClicked && !radioClicked.already) {
await page.mouse.click(radioClicked.x, radioClicked.y);
await page.waitForTimeout(300);
}
// 3. Fill Pattern field via clipboard paste
const patternId = await page.evaluate(findPatternInputIdScript(dialogForm));
if (!patternId) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
return;
}
await page.click(`[id="${patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(300);
// 4. Click "Найти"
const findBtn = await page.evaluate(findNamedButtonScript('Найти'));
if (findBtn) {
await page.mouse.click(findBtn.x, findBtn.y);
await page.waitForTimeout(2000);
}
// 5. Close advanced search dialog
for (let attempt = 0; attempt < 3; attempt++) {
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
if (!dialogVisible) break;
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
await waitForStable(formNum);
} catch { /* silently fail — caller will re-scan and handle not_found */ }
}
/**
* Pick a value from an opened selection form.
*
* Strategy (escalating):
* 1. Scan visible rows for text match (exact startsWith includes)
* 2. Advanced search (Alt+F, "по части строки") re-scan
* 3. Fallback: simple search (search input + Enter) re-scan
* 4. Not found Escape error
*
* For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan.
* For empty search: pick first visible row.
*
* @param {number} selFormNum - selection form number
* @param {string} fieldName - field being filled (for error messages)
* @param {string|Object} search - string for simple search, or { field: value } for per-field search
* @param {number} origFormNum - original form number (to verify we returned)
* @returns {{ field, ok, method }} or {{ field, error, message }}
*/
export async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) {
const searchText = typeof search === 'string'
? search : (search ? Object.values(search).join(' ') : '');
const searchLower = normYo((searchText || '').toLowerCase());
// Helper: try to select a row; returns result if ok, null if item wasn't selectable (group).
let hadUnselectableMatch = false;
async function trySelect(row) {
const r = await dblclickAndVerify(row, selFormNum, fieldName);
if (r.ok) return r;
hadUnselectableMatch = true; // found match but couldn't select (possibly group row or overlay)
return null; // form still open, try next step
}
// Step 1: Scan visible rows (no filtering)
if (searchLower) {
const row = await scanGridRows(selFormNum, searchLower);
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
// Step 2: Advanced search (Alt+F — fast, no overlay issues)
if (typeof search === 'object' && search) {
// Per-field advanced search via filterList(val, {field})
for (const [fld, val] of Object.entries(search)) {
try {
await filterList(String(val), { field: fld });
} catch (e) {
// Re-throw programming errors (e.g. a missing import surfacing as
// ReferenceError) — only field-filter failures (not found / unsupported
// column) should be swallowed so we fall through to the re-scan.
if (e instanceof ReferenceError || e instanceof TypeError) throw e;
/* proceed */
}
}
} else if (searchLower) {
// Inline advanced search (Alt+F, "по части строки")
await advancedSearchInline(selFormNum, searchText);
}
if (searchLower) {
const row = await scanGridRows(selFormNum, searchLower);
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
// Step 3: Fallback — simple search via search input (for forms without Alt+F support)
if (typeof search === 'string' && searchLower) {
const searchInputInfo = await page.evaluate(findSearchInputScript(selFormNum));
if (searchInputInfo) {
try {
await page.click(`[id="${searchInputInfo.id}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(searchText);
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await waitForStable(selFormNum);
} catch { /* proceed */ }
const row = await scanGridRows(selFormNum, searchLower);
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
}
// Step 4: Empty search → pick first row; otherwise not found
if (!searchLower) {
const row = await scanGridRows(selFormNum, '');
if (row?.x) {
const r = await trySelect(row);
if (r) return r;
}
}
await page.keyboard.press('Escape');
await waitForStable();
const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search);
if (hadUnselectableMatch) {
return { field: fieldName, error: 'not_selectable',
message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' };
}
return { field: fieldName, error: 'not_found',
message: 'No matches in selection form for ' + searchDesc };
}
/**
* Detect whether a form is a type selection dialog ("Выбор типа данных").
* Type dialogs appear when selecting a value for a composite-type field.
*
* Detection signals (any one is sufficient):
* - form{N}_OK element exists (selection forms use "Выбрать", not "OK")
* - form{N}_ValueList grid exists (specific to type/value list dialogs)
* - Window title contains "Выбор типа" (title attr on .toplineBoxTitle)
*/
export async function isTypeDialog(formNum) {
return page.evaluate(isTypeDialogScript(formNum));
}
/**
* Select a type from the type selection dialog ("Выбор типа данных")
* using Ctrl+F search. The dialog has a virtual grid (~5 visible rows),
* so Ctrl+F is the only reliable way to find a type.
*
* Algorithm: Ctrl+F paste typeName Enter (search) Escape (close Find)
* verify selected row matches Enter (OK)
*
* @param {number} formNum - type dialog form number
* @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт")
* @throws {Error} if type not found
*/
export async function pickFromTypeDialog(formNum, typeName) {
// The type dialog is a modal ValueList grid.
// Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists.
//
// Key constraints discovered during testing:
// - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true})
// which punches through the modal overlay to the form underneath
// - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog)
// - Buttons: use page.click({force:true}), NOT evaluate(() => el.click())
// because evaluate click doesn't trigger 1C's event chain properly
// - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти"
// - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open
const typeNorm = normYo(typeName.toLowerCase());
// Helper: read visible rows and find matching ones
async function readVisibleRows() {
return page.evaluate(readTypeDialogVisibleRowsScript(formNum, typeNorm));
}
// Helper: dismiss the type-selection dialog (and any child "Найти") on error.
// Escape closes the dialog chain, but a blind Escape×3 cascades into the underlying
// form. So press Escape only while THIS type dialog is still present, then stop —
// leaving the source form (and cell edit mode) for the caller to handle.
async function dismissTypeDialog() {
for (let i = 0; i < 4; i++) {
const stillOpen = await page.evaluate(
`!!document.getElementById('form${formNum}_OK') || !!document.getElementById('form${formNum}_ValueList')`);
if (!stillOpen) break;
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
}
// Exact-match preference: substring search can surface several types that merely CONTAIN the
// requested name (e.g. "Контрагент" → "Банковская карта контрагента", "Договор с контрагентом",
// …, "Контрагент"). Prefer the row equal to the requested name; only the absence of a single
// exact match among multiple substring hits is a genuine ambiguity.
function resolveExact(matches) {
if (!matches || matches.length === 0) return null;
if (matches.length === 1) return matches[0];
const exact = matches.filter(m => normYo((m.text || '').toLowerCase()) === typeNorm);
return exact.length === 1 ? exact[0] : null;
}
async function selectRowAndOk(row) {
await page.mouse.click(row.x, row.y);
await page.waitForTimeout(200);
await page.click(`#form${formNum}_OK`, { force: true });
await page.waitForTimeout(ACTION_WAIT);
}
// Focus the grid via evaluate (does NOT punch through the modal overlay like page.click).
async function focusGrid() {
await page.evaluate(`(() => {
const grid = document.getElementById('form${formNum}_ValueList');
if (!grid) return;
const body = grid.querySelector('.gridBody');
if (body) body.focus(); else grid.focus();
})()`);
}
// Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists)
const scan = await readVisibleRows();
const scanPick = resolveExact(scan.matches);
if (scanPick) { await selectRowAndOk(scanPick); return; }
if (scan.matches.length > 1) {
await dismissTypeDialog();
await waitForStable();
throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
}
// Step 2: Not in visible rows — Ctrl+F jumps near the match in the large virtual list.
await focusGrid();
await page.waitForTimeout(300);
// Ctrl+F to open "Найти" dialog
await page.keyboard.press('Control+f');
await page.waitForTimeout(1000);
// Paste search text (focus is on "Что искать" field)
await page.keyboard.press('Control+a');
await pasteText(typeName);
await page.waitForTimeout(300);
// Find the "Найти" dialog form number (it's > formNum)
const findFormNum = await page.evaluate(findChildFormByButtonScript(formNum, 'Find'));
if (findFormNum === null) {
await dismissTypeDialog();
await waitForStable();
throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection');
}
// Click "Найти" — search is client-side (no server round-trip)
await page.click(`#form${findFormNum}_Find`, { force: true });
// "Найти" positions at the first match; the exact row is at or just below it. Read, and if the
// exact match is not yet in view, PageDown a few times (bounded) — virtualised grid, scrollTop
// stays 0 but the visible window changes. Poll each window for matches to settle.
let resolved = null, lastMatches = [], sawMatches = false;
for (let pageStep = 0; pageStep <= 3; pageStep++) {
if (pageStep > 0) { await focusGrid(); await page.keyboard.press('PageDown'); }
let v = null;
for (let w = 0; w < 5; w++) {
await page.waitForTimeout(200);
v = await readVisibleRows();
if (v.matches.length) break;
}
if (v && v.matches.length) {
sawMatches = true;
lastMatches = v.matches;
resolved = resolveExact(v.matches);
if (resolved) break;
// matches present but no single exact in this window — scroll to look just below
} else if (sawMatches) {
break; // scrolled past the matches without finding an exact one
}
}
if (resolved) { await selectRowAndOk(resolved); return; }
await dismissTypeDialog();
await waitForStable();
if (!sawMatches) {
throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` +
`. Visible: ${(scan.visible || []).join(', ')}`);
}
throw new Error(`selectValue: multiple types match "${typeName}": ${lastMatches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
}
/**
* Fill a reference field via clipboard paste + 1C autocomplete.
*
* Strategy:
* 1. Clear field if it has a value (Shift+F4 native 1C mechanism, no JS errors)
* 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete)
* 3. Check editDropDown for autocomplete results click match or Tab to resolve
* 4. Verify result: resolved ok, not found clear + error
*
* Clipboard paste was chosen because:
* - Ctrl+V produces trusted browser events that 1C respects for autocomplete
* - page.fill() + synthetic keydown/keyup only triggers hints, not real search
* - keyboard.type() garbles Cyrillic on some fields
*
* @returns {{ field, ok?, method?, error?, value?, message?, available? }}
*/
export async function fillReferenceField(selector, fieldName, value, formNum) {
const text = String(value);
const escapedSel = selector.replace(/'/g, "\\'");
// Helper: detect new forms opened above the current one (strict — interactive
// elements only; fillReferenceField-specific)
const detectNewForm = () => helperDetectNewForm(formNum, { strict: true });
// Helper: clear the field using Shift+F4 (native 1C mechanism)
async function clearField() {
try {
await page.click(selector, { timeout: 3000 });
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
} catch { /* OK */ }
}
// Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке")
async function checkNotInListCloud() {
return page.evaluate(isNotInListCloudVisibleScript());
}
// 0. Dismiss any leftover error modal from a previous operation
await dismissPendingErrors();
// 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields
// and also for reference fields that show a dropdown.
const inputId = selector.match(/\[id="(.+)"\]/)?.[1];
// DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB)
const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB';
const dlbSelector = `[id="${dlbId}"]`;
try {
const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`);
if (dlbVisible) {
await page.click(dlbSelector);
await page.waitForTimeout(1000);
const eddState = await readEdd();
if (eddState.visible && eddState.items?.length > 0) {
const target = normYo(text.toLowerCase());
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target);
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!match) match = candidates.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name.includes(target) || target.includes(name);
});
if (match) {
await page.mouse.click(match.x, match.y);
await waitForStable();
await dismissPendingErrors();
return { field: fieldName, ok: true, method: 'dropdown',
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
}
// No match in DLB dropdown — close and fall through to paste approach
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
} else if (eddState.visible) {
// DLB opened a hint popup (no .eddText items) — close it before proceeding
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
}
} catch { /* DLB approach failed — fall through to paste */ }
// 1. Focus (handle surface/modal overlay from previous interaction)
await safeClick(selector, { dismissErrors: true });
// 2. If field already has a value, clear using Shift+F4 (native 1C mechanism).
// This is needed for reference fields — Shift+F4 properly clears the ref link.
const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
if (currentVal) {
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(500);
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
// Refocus
await page.click(selector);
}
// 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete)
await pasteText(text);
await page.waitForTimeout(2000);
// 4. Check editDropDown for autocomplete suggestions
const eddState = await readEdd();
if (eddState.visible && eddState.items?.length > 0) {
const target = normYo(text.toLowerCase());
// Separate real matches from "Создать:" items
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
if (candidates.length > 0) {
// Find best match (items have format "Name (Code)" — match against name part)
let match = candidates.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name === target;
});
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!match) match = candidates.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name.includes(target) || target.includes(name);
});
if (match) {
await page.mouse.click(match.x, match.y);
await waitForStable();
await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async
return { field: fieldName, ok: true, method: 'dropdown',
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
}
// Candidates exist but none match — report them
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_matched',
available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
}
// Only "Создать:" items — no existing matches
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_found',
message: 'No existing values match "' + text + '"' };
}
// 4b. No edd — check for "not in list" cloud that may have appeared during paste
if (await checkNotInListCloud()) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found (not in list)' };
}
// 5. No edd at all — press Tab to trigger direct resolve
await page.keyboard.press('Tab');
await waitForStable();
await dismissPendingErrors();
// 5x. Check for "not in list" cloud popup after Tab
if (await checkNotInListCloud()) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found (not in list)' };
}
// 5a. New form opened? (creation form = value not found)
const newForm = await detectNewForm();
if (newForm !== null) {
await page.keyboard.press('Escape');
await waitForStable();
await clearField();
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found' };
}
// 5b. Dropdown after Tab?
const popup = await page.evaluate(readSubmenuScript());
if (Array.isArray(popup) && popup.length > 0) {
const realItems = popup.filter(i => !i.name.startsWith('Создать'));
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await clearField();
if (realItems.length > 0) {
return { field: fieldName, error: 'ambiguous',
message: 'Multiple matches for "' + text + '"',
available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
}
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found' };
}
// 5c. Check final value
const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
if (!finalVal) {
// 6. Last resort: try F4 to open selection form and pick from there
try {
await page.click(selector);
await page.waitForTimeout(300);
} catch { /* OK — field may be unfocused */ }
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
const selFormNum = await detectNewForm();
if (selFormNum !== null) {
const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum);
if (pickResult.ok) return pickResult;
// pickFromSelectionForm already closed the form on error
}
return { field: fieldName, error: 'not_found',
message: 'Value "' + text + '" not found (field is empty)' };
}
return { field: fieldName, ok: true, method: 'typeahead', value: finalVal };
}
/**
* Select a value from a reference field (compound operation).
* Handles three patterns:
* A) DLB opens an inline dropdown popup click matching item
* B) DLB opens dropdown with history click "Показать все" or F4 to open selection form
* C) DLB opens a separate selection form directly search + dblclick in grid
*/
export async function selectValue(fieldName, searchText, { type } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error(`selectValue: no form found`);
// Detect any new form opened above this one (broad — includes type dialogs).
// Hoisted to the top so the composite-type branch can call it before its
// original declaration site further below.
const detectNewForm = () => helperDetectNewForm(formNum);
// 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields)
let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB'));
if (btn?.error === 'button_not_found') {
btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB'));
}
if (btn?.error) return btn;
if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {}
try {
// === CLEAR FIELD if searchText is empty/null ===
if (!searchText && searchText !== 0) {
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (inputId) {
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await waitForStable();
}
if (highlightMode) try { await unhighlight(); } catch {}
return returnFormState({ selected: { field: fieldName, search: null, method: 'clear' } });
}
// === COMPOSITE TYPE HANDLING ===
// When `type` is specified, clear the field first to reset cached type,
// then open type selection dialog, pick the type, then pick the value.
if (type) {
// Find and focus the field input
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`);
// Clear cached type + value with Shift+F4
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(500);
// Re-focus and press F4 to open type selection dialog
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
await waitForStable(formNum);
const newFormNum = await detectNewForm();
if (newFormNum === null) {
throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`);
}
if (await isTypeDialog(newFormNum)) {
// Pick type from the dialog
await pickFromTypeDialog(newFormNum, type);
await waitForStable(newFormNum);
// After type selection, the actual selection form should open
const selFormNum = await detectSelectionForm();
if (selFormNum === null) {
throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`);
}
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
const state = await getFormState();
state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' };
if (pickResult.error) state.selected.error = pickResult.error;
if (pickResult.message) state.selected.message = pickResult.message;
const err = await checkForErrors();
if (err) state.errors = err;
return state;
} else {
// Not a type dialog — field is not composite type, proceed with normal selection
const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum);
const state = await getFormState();
state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
if (pickResult.error) state.selected.error = pickResult.error;
if (pickResult.message) state.selected.message = pickResult.message;
const err = await checkForErrors();
if (err) state.errors = err;
return state;
}
}
// === END COMPOSITE TYPE HANDLING ===
// Auto-enable DCS checkbox if resolved via label
if (btn.dcsCheckbox) {
const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`;
const isChecked = await page.$eval(cbSel, el =>
el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'));
if (!isChecked) { await page.click(cbSel); await waitForStable(); }
}
// Helper: detect selection form (form number > formNum, strict mode)
async function detectSelectionForm() {
return helperDetectNewForm(formNum, { strict: true });
}
// detectNewForm is hoisted at the top of selectValue (see above).
// Helper: open selection form and pick value
async function openFormAndPick() {
await waitForStable(formNum);
const selFormNum = await detectSelectionForm();
if (selFormNum !== null) {
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
if (pickResult.error) selected.error = pickResult.error;
if (pickResult.message) selected.message = pickResult.message;
return returnFormState({ selected });
}
return null;
}
// Locals → dom-scripts in helpers.mjs (see clickEddItemViaDispatch / clickShowAllInEdd)
const clickEddItem = clickEddItemViaDispatch;
const clickShowAll = clickShowAllInEdd;
// 2. Click DLB (handle funcPanel / surface overlay intercept)
const dlbSel = `[id="${btn.buttonId}"]`;
await safeClick(dlbSel, { timeout: 5000 });
await page.waitForTimeout(ACTION_WAIT);
// 3A. Check if a dropdown popup appeared (inline quick selection)
const popupItems = await page.evaluate(readSubmenuScript());
if (Array.isArray(popupItems) && popupItems.length > 0) {
const regularItems = popupItems.filter(i => i.kind !== 'showAll');
const showAllItem = popupItems.find(i => i.kind === 'showAll');
if (searchText && typeof searchText !== 'string') {
// Object search ({field: value}) can't be matched against dropdown item
// text — close the typeahead popup and open the full selection form, which
// handles per-field advanced search (pickFromSelectionForm → filterList).
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (inputId) { await page.click(`[id="${inputId}"]`); await page.waitForTimeout(300); }
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
const formResult = await openFormAndPick();
if (formResult) return formResult;
throw new Error(`selectValue: object search ${JSON.stringify(searchText)} for "${btn.fieldName}" did not open a selection form`);
}
if (searchText) {
const target = normYo(searchText.toLowerCase());
// Try to find match among regular dropdown items
let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target);
if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target));
if (!match) match = regularItems.find(i => {
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
return name === target || name.includes(target) || target.includes(name);
});
if (match) {
// Click via evaluate to bypass div.surface overlay
await clickEddItem(match.name);
await waitForStable();
return returnFormState({ selected: { field: btn.fieldName, search: searchText, method: 'dropdown' } });
}
// No match in dropdown — try "Показать все" to open selection form
if (showAllItem) {
await clickShowAll();
const formResult = await openFormAndPick();
if (formResult) return formResult;
}
// No "Показать все" — close dropdown, try F4
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
// Focus the field input and press F4 to open selection form
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (inputId) {
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
}
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
const formResult = await openFormAndPick();
if (formResult) return formResult;
// Still nothing — report available items from original dropdown
throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`);
}
// No search text — click first regular item
if (regularItems.length > 0) {
await clickEddItem(regularItems[0].name);
await waitForStable();
return returnFormState({ selected: { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' } });
}
}
// 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs)
const selFormNum = await detectNewForm();
if (selFormNum !== null) {
// Auto-detect type selection dialog when `type` was not specified
if (await isTypeDialog(selFormNum)) {
await page.keyboard.press('Escape');
await waitForStable();
throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`);
}
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
if (pickResult.error) selected.error = pickResult.error;
if (pickResult.message) selected.message = pickResult.message;
return returnFormState({ selected });
}
// 3C. Neither popup nor form — try F4 as last resort
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
const inputId = await findFieldInputId(formNum, btn.fieldName);
if (inputId) {
await page.click(`[id="${inputId}"]`);
await page.waitForTimeout(300);
}
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
const formResult = await openFormAndPick();
if (formResult) return formResult;
throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`);
} finally { if (highlightMode) try { await unhighlight(); } catch {} }
}
@@ -0,0 +1,32 @@
// web-test engine/forms/state v1.17 — central form-state reader.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// getFormState — the canonical "what's on the screen right now" call. Combines:
// 1. DOM script (getFormStateScript) → form structure (fields, buttons, tables, openForms, ...)
// 2. checkForErrors → state.errors + state.confirmation hint
// 3. detectPlatformDialogs → state.platformDialogs (About / Support Info / Error Report)
//
// Returned by virtually every action-function as the "after" snapshot.
import { page, ensureConnected } from '../core/state.mjs';
import { getFormStateScript } from '../../dom.mjs';
import { checkForErrors, detectPlatformDialogs } from '../core/errors.mjs';
/** Read current form state. Single evaluate call via combined script. */
export async function getFormState() {
ensureConnected();
const state = await page.evaluate(getFormStateScript());
const err = await checkForErrors();
if (err) {
state.errors = err;
if (err.confirmation) {
state.confirmation = err.confirmation;
state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
}
}
// Detect platform-level dialogs (About, Support Info, Error Report)
// These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
const pd = await detectPlatformDialogs();
if (pd.length) state.platformDialogs = pd;
return state;
}
@@ -0,0 +1,253 @@
// web-test nav/navigation v1.17 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, highlightMode, resolveProjectPath,
} from '../core/state.mjs';
import {
readSectionsScript, readTabsScript, readCommandsScript,
navigateSectionScript, openCommandScript, switchTabScript,
detectFormScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import { returnFormState } from '../core/helpers.mjs';
// Static import — ESM cycle that resolves at call time.
import { pasteText } from '../core/clipboard.mjs';
import { getFormState } from '../forms/state.mjs';
/**
* Get current page state: active section, tabs.
* Combined into a single evaluate call.
*/
export async function getPageState() {
ensureConnected();
const { sections, tabs } = await page.evaluate(`({
sections: ${readSectionsScript()},
tabs: ${readTabsScript()}
})`);
const activeSection = sections.find(s => s.active)?.name || null;
const activeTab = tabs.find(t => t.active)?.name || null;
return { activeSection, activeTab, sections, tabs };
}
/** Read section panel + commands in a single evaluate call. */
export async function getSections() {
ensureConnected();
const { sections, commands } = await page.evaluate(`({
sections: ${readSectionsScript()},
commands: ${readCommandsScript()}
})`);
const activeSection = sections.find(s => s.active)?.name || null;
return { activeSection, sections, commands };
}
/** Navigate to a section by name. Returns new state with commands. */
export async function navigateSection(name) {
ensureConnected();
await dismissPendingErrors();
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
const result = await page.evaluate(navigateSectionScript(name));
if (result?.error) {
const avail = result.available?.filter(Boolean);
if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`);
throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`);
}
await waitForStable();
const { sections, commands } = await page.evaluate(`({
sections: ${readSectionsScript()},
commands: ${readCommandsScript()}
})`);
return returnFormState({ navigated: result, sections, commands });
}
/** Read commands of the current section. */
export async function getCommands() {
ensureConnected();
return await page.evaluate(readCommandsScript());
}
/** Open a command from function panel by name. Returns new form state. */
export async function openCommand(name) {
ensureConnected();
await dismissPendingErrors();
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
const formBefore = await page.evaluate(detectFormScript());
const result = await page.evaluate(openCommandScript(name));
if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
await waitForStable(formBefore);
return await returnFormState();
}
/** Switch to an open tab by name (fuzzy match). Returns updated form state. */
export async function switchTab(name) {
ensureConnected();
const result = await page.evaluate(switchTabScript(name));
if (result?.error) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
await waitForStable();
return returnFormState();
}
// English → Russian metadata type mapping for e1cib navigation links
const E1CIB_TYPE_MAP = {
'catalog': 'Справочник', 'catalogs': 'Справочник',
'document': 'Документ', 'documents': 'Документ',
'commonmodule': 'ОбщийМодуль',
'enum': 'Перечисление', 'enums': 'Перечисление',
'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка',
'report': 'Отчет', 'reports': 'Отчет',
'accumulationregister': 'РегистрНакопления',
'informationregister': 'РегистрСведений',
'accountingregister': 'РегистрБухгалтерии',
'calculationregister': 'РегистрРасчета',
'chartofaccounts': 'ПланСчетов',
'chartofcharacteristictypes': 'ПланВидовХарактеристик',
'chartofcalculationtypes': 'ПланВидовРасчета',
'businessprocess': 'БизнесПроцесс',
'task': 'Задача',
'exchangeplan': 'ПланОбмена',
'constant': 'Константа',
};
// Types that open via e1cib/app/ (reports and data processors have their own app forms)
const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']);
function normalizeE1cibUrl(url) {
// Already a full e1cib link
if (url.startsWith('e1cib/')) return url;
// "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix
const dot = url.indexOf('.');
if (dot > 0) {
const typePart = url.substring(0, dot);
const namePart = url.substring(dot + 1);
const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart;
const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list';
return `${prefix}/${ruType}.${namePart}`;
}
return `e1cib/list/${url}`;
}
/**
* Open an external data processor or report (EPF/ERF) via File Open menu.
* Handles the security confirmation dialog on first open.
* @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd)
* @returns {Promise<object>} form state of the opened processor/report
*/
export async function openFile(filePath) {
ensureConnected();
await dismissPendingErrors();
const absPath = resolveProjectPath(filePath.replace(/\\/g, '/'));
const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const formBefore = await page.evaluate(detectFormScript());
// 1. Ctrl+O opens 1C's "Выбор файлов" dialog
await page.keyboard.press('Control+o');
// 2. Wait for the file selection dialog
const dialogOk = await waitForCondition(`(() => {
const ok = document.querySelector('#fileSelectDialogOk');
return ok && ok.offsetWidth > 0 ? true : false;
})()`, 3000);
if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)");
// 3. Click "выберите с диска" to trigger the native OS file picker
let fileChooser;
try {
[fileChooser] = await Promise.all([
page.waitForEvent('filechooser', { timeout: 5000 }),
page.click('a.underline.pointer'),
]);
} catch (e) {
// Try closing the dialog before throwing
await page.keyboard.press('Escape');
throw new Error(`File chooser did not appear: ${e.message}`);
}
// 4. Set the file path and click OK
await fileChooser.setFiles(absPath);
await page.waitForTimeout(500);
await page.click('#fileSelectDialogOk');
await waitForStable(formBefore);
// 5. Check for security dialog
const err = await checkForErrors();
if (err?.confirmation) {
// Security confirmation — click the positive button (Продолжить/Да/OK)
const positiveBtn = err.confirmation.buttons.find(b =>
/продолжить|да|ok|yes|открыть/i.test(b)
) || err.confirmation.buttons[0];
if (positiveBtn) {
const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`);
for (const b of btns) {
const txt = (await b.textContent())?.trim();
if (txt === positiveBtn) { await b.click(); break; }
}
await waitForStable(formBefore);
}
// After confirmation, check if EPF form appeared or a follow-up dialog showed.
// Check form change FIRST — avoids confusing a small EPF form with a modal dialog.
const formAfter = await page.evaluate(detectFormScript());
if (formAfter != null && formAfter !== formBefore) {
// New form appeared — but is it the EPF or an informational dialog?
// Informational "re-open" dialogs are tiny (< 20 elements).
const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`);
if (elCount < 20) {
// Likely an info dialog — check and dismiss
const err2 = await checkForErrors();
if (err2?.modal) {
await dismissPendingErrors();
await waitForStable(formBefore);
continue; // retry open cycle
}
}
// It's the real EPF form
return returnFormState({ opened: { file: absPath, attempt: attempt + 1 } });
}
// Form didn't appear — retry
continue;
}
// No security dialog — check if form appeared
if (err?.modal) {
throw new Error(`Error opening file: ${err.modal.message}`);
}
const formAfter = await page.evaluate(detectFormScript());
if (formAfter != null && formAfter !== formBefore) {
const state = await getFormState();
state.opened = { file: absPath, attempt: attempt + 1 };
return state;
}
}
throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`);
}
/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */
export async function navigateLink(url) {
ensureConnected();
await dismissPendingErrors();
const link = normalizeE1cibUrl(url);
const formBefore = await page.evaluate(detectFormScript());
// Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
await waitForStable();
// Click "Перейти" in the navigation dialog
const dialog = await page.evaluate(detectFormScript());
if (dialog != null && dialog !== formBefore) {
const btns = await page.$$(`#form${dialog}_container a.press`);
for (const b of btns) {
const txt = (await b.textContent())?.trim();
if (txt === 'Перейти') { await b.click(); break; }
}
}
await waitForStable(formBefore);
return await returnFormState();
}
@@ -0,0 +1,292 @@
// web-test recording/captions v1.17 — Overlay primitives: captions, title slides, image overlays.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync as fsExistsSync, readFileSync } from 'fs';
import { extname } from 'path';
import {
page, recorder, lastCaptions, ensureConnected, resolveProjectPath,
} from '../core/state.mjs';
/**
* Show a text caption overlay on the page (visible in recording).
* Calling again updates the text without creating a new element.
* @param {string} text caption text
* @param {object} [opts]
* @param {'top'|'bottom'} [opts.position='bottom'] vertical position
* @param {number} [opts.fontSize=24] font size in pixels
* @param {string} [opts.background='rgba(0,0,0,0.7)'] background color
* @param {string} [opts.color='#fff'] text color
* @param {string|false} [opts.speech] TTS narration text. Omit to use displayed text,
* pass a string for custom narration, or false to skip narration for this caption.
*/
export async function showCaption(text, opts = {}) {
ensureConnected();
// Collect caption for TTS narration if recording
let smartWaitMs = 0;
if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) {
const speech = typeof opts.speech === 'string' ? opts.speech : text;
// Use video timeline position (accounts for frame duplication) instead of wall-clock
recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
// Estimate TTS duration and wait so the video has enough screen time for voiceover
smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
}
const position = opts.position || 'bottom';
const fontSize = opts.fontSize || 24;
const bg = opts.background || 'rgba(0,0,0,0.7)';
const color = opts.color || '#fff';
await page.evaluate(({ text, position, fontSize, bg, color }) => {
let el = document.getElementById('__web_test_caption');
if (!el) {
el = document.createElement('div');
el.id = '__web_test_caption';
el.style.cssText = `
position: fixed; left: 0; right: 0; z-index: 99999;
text-align: center; padding: 12px 24px;
font-family: Arial, sans-serif; pointer-events: none;
`;
document.body.appendChild(el);
}
el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
el.style.fontSize = fontSize + 'px';
el.style.background = bg;
el.style.color = color;
el.textContent = text;
}, { text, position, fontSize, bg, color });
// Smart TTS wait: pause for estimated speech duration so video has enough screen time.
// Split into chunks and flush frames periodically — CDP doesn't send screencast frames
// for static pages, so we must write duplicate frames to keep video timeline in sync.
if (smartWaitMs > 0) {
let remaining = smartWaitMs;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
if (recorder?._flushFrames) recorder._flushFrames();
}
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
}
}
/** Remove the caption overlay from the page. */
export async function hideCaption() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_caption');
if (el) el.remove();
});
}
/**
* Get captions collected during the current or last recording.
* @returns {Array<{text: string, speech: string, time: number}>}
*/
export function getCaptions() {
if (recorder) return [...recorder.captions];
return [...lastCaptions];
}
/**
* Show a full-screen title slide overlay (for video recordings).
* Repeated calls update the content. Use hideTitleSlide() to remove.
* @param {string} text Title text (\n line break)
* @param {object} [opts]
* @param {string} [opts.subtitle] Smaller text below the title
* @param {string} [opts.background] CSS background (default: dark gradient)
* @param {string} [opts.color] Text color (default: '#fff')
* @param {number} [opts.fontSize] Title font size in px (default: 36)
*/
export async function showTitleSlide(text, opts = {}) {
ensureConnected();
const {
subtitle = '',
background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
color = '#fff',
fontSize = 36,
speech,
} = opts;
// Collect caption for TTS narration if recording
let smartWaitMs = 0;
if (recorder && speech && speech !== false) {
const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
if (captionText) {
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
}
}
await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
let div = document.getElementById('__web_test_title');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_title';
document.body.appendChild(div);
}
div.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
`background:${background}`,
'display:flex', 'align-items:center', 'justify-content:center',
'z-index:999999', 'pointer-events:none',
].join(';');
// Remove other overlays to prevent flash between slides
const img = document.getElementById('__web_test_image');
if (img) img.remove();
const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>');
let html = `<div style="font-size:${fontSize}px;font-weight:600;line-height:1.4;">${esc(text)}</div>`;
if (subtitle) {
html += `<div style="font-size:${Math.round(fontSize * 0.5)}px;margin-top:16px;opacity:0.7;">${esc(subtitle)}</div>`;
}
div.innerHTML = `<div style="text-align:center;max-width:70%;color:${color};font-family:'Segoe UI',Arial,sans-serif;">${html}</div>`;
}, { text, subtitle, background, color, fontSize });
// Smart TTS wait (same pattern as showCaption/showImage)
if (smartWaitMs > 0) {
let remaining = smartWaitMs;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
if (recorder?._flushFrames) recorder._flushFrames();
}
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
}
}
/** Remove the title slide overlay. */
export async function hideTitleSlide() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_title');
if (el) el.remove();
});
}
/**
* Show a full-screen image overlay (e.g. presentation slide screenshot).
* Reads the image file, base64-encodes it, and renders as a fixed overlay
* on the page captured by CDP screencast automatically.
*
* Style presets:
* - 'blur' (default) blurred+dimmed copy as background, image centered with shadow
* - 'dark' dark background (#2a2a2a) with shadow
* - 'light' white background with shadow
* - 'full' image covers entire screen, no padding/shadow
*
* Custom background overrides the preset (e.g. background: '#003366').
*
* @param {string} imagePath path to the image file (PNG, JPG, etc.)
* @param {object} [opts]
* @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] display style preset
* @param {string} [opts.background] custom background color/gradient (overrides style preset)
* @param {boolean} [opts.shadow] show drop shadow (default: true for blur/dark/light, false for full)
* @param {string|false} [opts.speech] TTS narration text while image is shown.
* Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
*/
export async function showImage(imagePath, opts = {}) {
ensureConnected();
const style = opts.style || 'blur';
const speech = opts.speech;
// Style presets
const presets = {
blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
};
const preset = presets[style] || presets.blur;
const bg = opts.background || preset.bg;
const fit = preset.fit;
const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
const useBlur = opts.background ? false : preset.blur;
// Read image and base64-encode
const absPath = resolveProjectPath(imagePath);
if (!fsExistsSync(absPath)) {
throw new Error(`showImage: file not found: ${absPath}`);
}
const buf = readFileSync(absPath);
const ext = extname(absPath).toLowerCase().replace('.', '');
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'png' ? 'image/png'
: ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp'
: ext === 'svg' ? 'image/svg+xml'
: 'image/png';
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
// Collect caption for TTS narration if recording
let smartWaitMs = 0;
if (recorder && speech && speech !== false) {
const captionText = typeof speech === 'string' ? speech : '';
if (captionText) {
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
}
}
// Padding: full style uses 100%, others use 92% for breathing room
const isFull = style === 'full';
const maxSize = isFull ? '100%' : '92%';
await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
let div = document.getElementById('__web_test_image');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_image';
document.body.appendChild(div);
}
// Remove other overlays to prevent flash between slides
const title = document.getElementById('__web_test_title');
if (title) title.remove();
div.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
`background:${bg}`,
'display:flex', 'align-items:center', 'justify-content:center',
'z-index:999999', 'pointer-events:none', 'overflow:hidden'
].join(';');
let html = '';
// Blurred background layer: the same image stretched to cover, blurred and dimmed
if (useBlur) {
html += `<img src="${dataUrl}" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;filter:blur(30px) brightness(0.5);transform:scale(1.1);" />`;
}
// Main image
const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
const sizeCss = isFull
? `width:100%;height:100%;object-fit:${fit};`
: `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
html += `<img src="${dataUrl}" style="position:relative;${sizeCss}${shadowCss}" />`;
div.innerHTML = html;
}, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
// Smart TTS wait (same pattern as showCaption)
if (smartWaitMs > 0) {
let remaining = smartWaitMs;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
if (recorder?._flushFrames) recorder._flushFrames();
}
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
}
}
/** Remove the image overlay. */
export async function hideImage() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_image');
if (el) el.remove();
});
}
@@ -0,0 +1,243 @@
// web-test recording/capture v1.17 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { spawn } from 'child_process';
import { mkdirSync, statSync, writeFileSync } from 'fs';
import { dirname } from 'path';
import {
page, recorder, lastCaptions,
setRecorder, setLastCaptions, setLastRecordingDuration,
resolveProjectPath, ensureConnected,
} from '../core/state.mjs';
import { resolveFfmpeg } from './tts.mjs';
// Imported lazily inside wait() to avoid initialization-time circular deps.
/** Take a screenshot. Returns PNG buffer. */
export async function screenshot() {
ensureConnected();
return await page.screenshot({ type: 'png' });
}
/** Wait for a specified number of seconds. */
export async function wait(seconds) {
ensureConnected();
let ms = seconds * 1000;
// Credit system: if showCaption already waited for TTS, subtract that time
if (recorder && recorder.captionCredit) {
const elapsed = Date.now() - recorder.captionCredit.at;
const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
ms = Math.max(0, ms - credit);
recorder.captionCredit = null;
}
if (ms > 0) {
// During recording, split long waits into chunks and flush frames
// to keep video timeline in sync (CDP may not send frames for static pages)
if (recorder?._flushFrames && ms > 1000) {
let remaining = ms;
while (remaining > 0) {
const chunk = Math.min(remaining, 1000);
await page.waitForTimeout(chunk);
remaining -= chunk;
recorder._flushFrames();
}
} else {
await page.waitForTimeout(ms);
}
}
const { getFormState } = await import('../forms/state.mjs');
return await getFormState();
}
// ============================================================
// Video recording — CDP screencast + ffmpeg
// ============================================================
/** Check if video recording is active. */
export function isRecording() {
return recorder !== null;
}
/**
* Start video recording via CDP screencast + ffmpeg.
* Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
* @param {string} outputPath output .mp4 file path
* @param {object} [opts]
* @param {number} [opts.fps=25] target framerate
* @param {number} [opts.quality=80] JPEG quality (1-100)
* @param {string} [opts.ffmpegPath] explicit path to ffmpeg binary
*/
export async function startRecording(outputPath, opts = {}) {
ensureConnected();
if (recorder) {
if (opts.force) {
try { await stopRecording(); } catch {}
} else {
throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
}
}
setLastCaptions([]);
setLastRecordingDuration(null);
const fps = opts.fps || 25;
const quality = opts.quality || 80;
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
// Ensure output directory exists
const resolvedPath = resolveProjectPath(outputPath);
mkdirSync(dirname(resolvedPath), { recursive: true });
// Spawn ffmpeg process — single output file across context switches
const ffmpeg = spawn(ffmpegPath, [
'-y', // overwrite output
'-f', 'image2pipe', // input: piped images
'-framerate', String(fps), // input framerate
'-i', '-', // read from stdin
'-c:v', 'libx264', // H.264 codec
'-preset', 'fast', // good quality/speed balance
'-crf', '23', // default quality (good for screen content)
'-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
'-pix_fmt', 'yuv420p', // broad compatibility
'-color_range', 'tv', // limited range (16-235) — standard for H.264 players
'-movflags', '+faststart', // web-friendly MP4
resolvedPath
], { stdio: ['pipe', 'ignore', 'pipe'] });
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
const frameDuration = 1000 / fps;
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
// Frame handler shared across CDP sessions (lives in recorder, not closure):
// when the active context switches, we attach a new CDP session and route its
// frames to the same ffmpeg pipe — preserving a single continuous timeline.
const frameHandler = async ({ data, sessionId }, cdp) => {
if (!recorder) return;
const buf = Buffer.from(data, 'base64');
const now = Date.now();
if (!ffmpeg.stdin.destroyed) {
let framesWritten = 0;
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
const gap = now - recorder.lastFrameTime;
const dupes = Math.round(gap / frameDuration) - 1;
for (let i = 0; i < dupes && i < fps * 30; i++) {
ffmpeg.stdin.write(recorder.lastFrameBuf);
framesWritten++;
}
}
ffmpeg.stdin.write(buf);
framesWritten++;
recorder.videoTimeMs += framesWritten * frameDuration;
}
recorder.lastFrameTime = now;
recorder.lastFrameBuf = buf;
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
};
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
const _flushFrames = () => {
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
const now = Date.now();
const gap = now - recorder.lastFrameTime;
const dupes = Math.round(gap / frameDuration);
for (let i = 0; i < dupes; i++) {
ffmpeg.stdin.write(recorder.lastFrameBuf);
recorder.videoTimeMs += frameDuration;
}
if (dupes > 0) recorder.lastFrameTime = now;
};
// Attach screencast to a specific page. Stops the old CDP first (if any).
// Called by startRecording for the initial page, and by setActiveContext when
// the active context changes mid-recording.
const _attachPage = async (targetPage) => {
if (recorder.cdp) {
_flushFrames(); // freeze the last frame of the outgoing page up to "now"
try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
try { await recorder.cdp.detach(); } catch {}
recorder.cdp = null;
}
const cdp = await targetPage.context().newCDPSession(targetPage);
cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
recorder.cdp = cdp;
recorder.activePage = targetPage;
};
setRecorder({
cdp: null,
activePage: null,
ffmpeg,
startTime: Date.now(),
outputPath: resolvedPath,
ffmpegError: '',
captions: [],
videoTimeMs: 0,
frameDuration,
lastFrameTime: null,
lastFrameBuf: null,
_flushFrames,
_attachPage,
speechRate,
});
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
await _attachPage(page);
}
/**
* Stop video recording. Finalizes the MP4 file.
* @returns {{ file: string, duration: number, size: number }}
*/
export async function stopRecording() {
if (!recorder) return { file: null, duration: 0, size: 0 };
const { cdp, ffmpeg, startTime, outputPath } = recorder;
// Final frame flush: write remaining frames to cover the gap since the last screencast frame
if (recorder._flushFrames) recorder._flushFrames();
// Stop CDP screencast
try { await cdp.send('Page.stopScreencast'); } catch {}
try { await cdp.detach(); } catch {}
// Close ffmpeg stdin and wait for encoding to finish
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ffmpeg.kill('SIGKILL');
reject(new Error('ffmpeg timed out after 30s'));
}, 30000);
ffmpeg.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
});
ffmpeg.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
ffmpeg.stdin.end();
});
const duration = (Date.now() - startTime) / 1000;
const stats = statSync(outputPath);
// Preserve captions for addNarration()
setLastCaptions(recorder.captions || []);
setLastRecordingDuration(duration);
if (lastCaptions.length) {
const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
}
setRecorder(null);
return {
file: outputPath,
duration: Math.round(duration * 10) / 10,
size: stats.size,
captions: lastCaptions.length
};
}
@@ -0,0 +1,340 @@
// web-test recording/highlight v1.17 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, highlightMode, ensureConnected, normYo,
setHighlightMode,
} from '../core/state.mjs';
import {
readSubmenuScript, detectFormScript, resolveGridScript,
findClickTargetScript, resolveFieldsScript,
} from '../../dom.mjs';
/**
* Highlight an element on the page (visual accent for video recordings).
* Uses overlay div for visibility (not clipped by overflow:hidden), with
* requestAnimationFrame tracking so it follows layout shifts (async banners etc).
* @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
* @param {object} [opts]
* @param {string} [opts.color] Outline color (default: '#e74c3c')
* @param {number} [opts.padding] Extra padding around element (default: 4)
*/
export async function highlight(text, opts = {}) {
ensureConnected();
const { color = '#e74c3c', padding = 4, table } = opts;
// Remove previous highlight first
await unhighlight();
let elId = null;
// 0. Open submenu/popup — highest priority (submenu overlays the form,
// so form search would match grid rows behind the popup)
const popupItems = await page.evaluate(readSubmenuScript());
if (Array.isArray(popupItems) && popupItems.length > 0) {
const target = normYo(text.toLowerCase());
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
if (found) {
// 1C duplicates IDs in clouds — getElementById returns the hidden copy.
// Use elementFromPoint to find the visible element and get its actual rect.
await page.evaluate(({ x, y, color, padding }) => {
const el = document.elementFromPoint(x, y);
if (!el) return;
const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
const r = block.getBoundingClientRect();
let div = document.getElementById('__web_test_highlight');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_highlight';
document.body.appendChild(div);
}
div.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:999998',
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
`outline:3px solid ${color}`, 'border-radius:4px',
`box-shadow:0 0 16px ${color}80`,
].join(';');
}, { x: found.x, y: found.y, color, padding });
return; // overlay placed, done
}
}
// 1. Visible commands on the function panel (cmd_XXX_txt elements)
// Must be checked BEFORE form search: when the section content panel
// is showing, the form behind it is hidden but detectFormScript still
// finds it, and form buttons match before commands.
if (!elId) {
elId = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
if (cmds.length === 0) return null;
let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
return el ? el.id : null;
})()`);
}
// 1b. Command group headers on the function panel (eAccentColor labels).
// Match header text, then highlight the header + commands below it
// until the next spacer/header/end.
if (!elId) {
const groupDone = await page.evaluate(({ target, color, padding }) => {
const container = document.querySelector('#funcPanel_container');
if (!container) return false;
const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
if (!headers.length) return false;
let headerEl = headers.find(h => norm(h.textContent) === target);
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
if (!headerEl) return false;
// Collect header + following cmd siblings until next spacer/header
const parent = headerEl.parentElement;
const children = [...parent.children];
const startIdx = children.indexOf(headerEl);
const groupEls = [headerEl];
for (let i = startIdx + 1; i < children.length; i++) {
const el = children[i];
if (el.classList.contains('eAccentColor')) break;
if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
groupEls.push(el);
}
// Bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of groupEls) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
}
if (minX === Infinity) return false;
let div = document.getElementById('__web_test_highlight');
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
div.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:999998',
`top:${minY - padding}px`, `left:${minX - padding}px`,
`width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
`outline:3px solid ${color}`, 'border-radius:4px',
`box-shadow:0 0 16px ${color}80`,
].join(';');
return true;
}, { target: normYo(text.toLowerCase()), color, padding });
if (groupDone) return;
}
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
// panel and a command bar element). Includes _container and _div elements
// but skips logicGroupContainer (Representation=None, height=0).
if (!elId) {
const formNum = await page.evaluate(detectFormScript());
if (formNum !== null) {
elId = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
const p = 'form' + ${formNum} + '_';
// Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
const items = groups.map(el => {
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
const titleEl = document.getElementById(p + idName + '#title_text')
|| document.getElementById(p + idName + '_title_text');
const label = norm(titleEl?.innerText || '').toLowerCase();
const name = norm(idName).toLowerCase();
const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
return { id: el.id, name, label, big };
});
let found = items.find(i => i.label === target);
if (!found) found = items.find(i => i.name === target);
// Fuzzy match: only large groups (min 100x50) to avoid matching command bars
if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
return found ? found.id : null;
})()`);
}
}
// 3. Form-scoped search (buttons, links, fields, grid rows)
if (!elId) {
const formNum = await page.evaluate(detectFormScript());
if (formNum !== null) {
// 3a. Try button/link/tab/gridRow via findClickTargetScript
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (!resolved.error) gridSelector = resolved.gridSelector;
}
const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
if (target && !target.error) {
if (target.id) {
elId = target.id;
} else if (target.x && target.y) {
// Grid row — find the gridLine element and tag it
elId = await page.evaluate(`(() => {
const p = ${JSON.stringify(`form${formNum}_`)};
const grid = document.querySelector('[id^="' + p + '"].grid');
if (!grid) return null;
const body = grid.querySelector('.gridBody');
if (!body) return null;
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
for (const line of body.querySelectorAll('.gridLine')) {
const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
if (rowText.includes(target)) {
if (!line.id) line.id = '__wt_hl_tmp';
return line.id;
}
}
return null;
})()`);
}
}
// 3b. If not found as button — try as field via resolveFieldsScript
if (!elId) {
const dummyFields = { [text]: '' };
const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
elId = resolved[0].inputId;
}
}
}
}
// 4. Fallback: sections (sidebar navigation)
if (!elId) {
elId = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
return el ? el.id : null;
})()`);
}
if (!elId) {
// Collect available elements to help the caller fix the name
const available = await page.evaluate(`(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
const result = {};
// Commands
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
if (cmds.length) result.commands = cmds;
// Command group headers
const fp = document.querySelector('#funcPanel_container');
if (fp) {
const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
if (gh.length) result.commandGroups = gh;
}
// Sections
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
if (secs.length) result.sections = secs;
// Form elements
${(() => {
// Detect form inline to avoid extra evaluate round-trip
return `
const forms = {};
document.querySelectorAll('[id^="form"]').forEach(el => {
const m = el.id.match(/^form(\\d+)_/);
if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
});
let formNum = null, maxCount = 0;
for (const [n, c] of Object.entries(forms)) {
if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
}
if (formNum !== null) {
const p = 'form' + formNum + '_';
// Groups (_container or _div, skip logicGroupContainer, min 100x50)
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
.filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
.map(el => {
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
return norm(titleEl?.innerText || '') || idName;
}).filter(Boolean);
if (groups.length) result.groups = groups;
// Buttons/links
const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
.filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
if (btns.length) result.buttons = [...new Set(btns)];
}`;
})()}
return result;
})()`);
const parts = [];
for (const [cat, items] of Object.entries(available)) {
parts.push(` ${cat}: ${items.join(', ')}`);
}
const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
throw new Error(`highlight: "${text}" not found${hint}`);
}
// Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
await page.evaluate(({ elId, color, padding }) => {
const target = document.getElementById(elId);
if (!target) return;
let div = document.getElementById('__web_test_highlight');
if (!div) {
div = document.createElement('div');
div.id = '__web_test_highlight';
document.body.appendChild(div);
}
function sync() {
const r = target.getBoundingClientRect();
div.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:999998',
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
`outline:3px solid ${color}`, 'border-radius:4px',
`box-shadow:0 0 16px ${color}80`,
].join(';');
}
sync();
// Track position changes via rAF
function tick() {
if (!document.getElementById('__web_test_highlight')) return; // stopped
sync();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}, { elId, color, padding });
}
/** Remove the highlight overlay. */
export async function unhighlight() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_highlight');
if (el) el.remove(); // also stops rAF loop (id check)
// Clean up temp ID from grid rows
const tmp = document.getElementById('__wt_hl_tmp');
if (tmp) tmp.removeAttribute('id');
});
}
/**
* Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
* automatically highlight the target element before acting.
* @param {boolean} on true to enable, false to disable
*/
export function setHighlight(on) {
setHighlightMode(!!on);
}
/** @returns {boolean} Whether auto-highlight mode is active. */
export function isHighlightMode() {
return highlightMode;
}
@@ -0,0 +1,196 @@
// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { execFileSync } from 'child_process';
import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
import { extname, join as pathJoin } from 'path';
import { tmpdir } from 'os';
import {
lastCaptions, lastRecordingDuration, resolveProjectPath,
} from '../core/state.mjs';
import {
resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence,
} from './tts.mjs';
/**
* Add TTS narration to a recorded video.
* Generates speech from captions and merges audio with the video.
* @param {string} videoPath path to the recorded MP4 file
* @param {object} [opts]
* @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
* @param {string} [opts.provider='edge'] TTS provider: 'edge' or 'openai'
* @param {string} [opts.voice] voice name (provider-specific)
* @param {string} [opts.apiKey] API key (for openai provider)
* @param {string} [opts.apiUrl] API endpoint (for openai provider)
* @param {string} [opts.model] model name (for openai provider, default: 'tts-1')
* @param {string} [opts.ffmpegPath] path to ffmpeg binary
* @param {string} [opts.outputPath] output file path (default: video-narrated.mp4)
* @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
*/
export async function addNarration(videoPath, opts = {}) {
if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
videoPath = resolveProjectPath(videoPath);
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
const ttsProvider = getTtsProvider(opts.provider || 'edge');
const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };
// Resolve captions: explicit > lastCaptions > .captions.json
let captions = opts.captions;
let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
if (!captions || !captions.length) {
if (lastCaptions.length) {
captions = [...lastCaptions];
recordingDuration = lastRecordingDuration;
// Runtime captions always use video timestamps (set in showCaption)
}
}
if (!captions || !captions.length) {
const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
if (fsExistsSync(captionsJsonPath)) {
const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
// Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
if (Array.isArray(raw)) {
captions = raw;
videoTimestamps = false;
} else {
captions = raw.captions;
videoTimestamps = !!raw.videoTimestamps;
recordingDuration = raw.recordingDuration || null;
}
}
}
if (!captions || !captions.length) {
throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
}
const videoDuration = getAudioDuration(videoPath, ffmpegPath);
// Legacy fallback: scale wall-clock timestamps to video duration
// (only for old captions without videoTimestamps flag)
if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
const timeScale = videoDuration / recordingDuration;
if (Math.abs(timeScale - 1) > 0.005) {
captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
}
}
// Output path
const ext = extname(videoPath);
const base = videoPath.slice(0, -ext.length);
const outputPath = opts.outputPath || `${base}-narrated${ext}`;
// Temp directory
const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
const warnings = [];
try {
// Phase 1: Generate TTS audio for each caption
const ttsFiles = [];
const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
const promises = batch.map(async (cap, batchIdx) => {
const idx = batchStart + batchIdx;
const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
try {
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
} catch (err) {
// Retry once
try {
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
} catch (retryErr) {
warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
// Generate 1s silence as placeholder
generateSilence(ttsFile, 1, ffmpegPath);
}
}
return ttsFile;
});
const results = await Promise.all(promises);
ttsFiles.push(...results);
}
// Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
// This avoids MP3 frame quantization drift from silence-file concatenation
const ffmpegInputs = [];
const filterParts = [];
const mixLabels = [];
for (let i = 0; i < captions.length; i++) {
const captionTimeMs = Math.round(captions[i].time);
const ttsFile = ttsFiles[i];
const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);
ffmpegInputs.push('-i', ttsFile);
const filters = [];
// Speed up TTS slightly if it's longer than gap to next caption (max 1.3x)
if (i < captions.length - 1) {
const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
if (ttsDuration > maxDuration && maxDuration > 0.1) {
const tempo = ttsDuration / maxDuration;
if (tempo <= 1.3) {
filters.push(`atempo=${tempo.toFixed(4)}`);
} else {
// Too fast — let audio overlap instead of distorting
warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
}
}
}
// Delay to exact caption timestamp (milliseconds)
if (captionTimeMs > 0) {
filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
}
const label = `a${i}`;
mixLabels.push(`[${label}]`);
// Input indices are shifted by 1 because silence reference is input [0]
filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
}
// Generate a silence reference track as input [0] so amix runs for full video duration
const silencePath = pathJoin(tempDir, 'silence.mp3');
generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
const filterComplex = filterParts.join(';') + ';' +
`[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
const narrationPath = pathJoin(tempDir, 'narration.mp3');
execFileSync(ffmpegPath, [
'-y', '-i', silencePath, ...ffmpegInputs,
'-filter_complex', filterComplex,
'-t', String(Math.ceil(videoDuration)),
'-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
], { stdio: 'pipe', timeout: 120000 });
// Phase 4: Merge video + narration audio
execFileSync(ffmpegPath, [
'-y', '-i', videoPath, '-i', narrationPath,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
'-map', '0:v:0', '-map', '1:a:0',
'-t', String(Math.ceil(videoDuration)),
'-movflags', '+faststart', outputPath,
], { stdio: 'pipe', timeout: 120000 });
const stats = statSync(outputPath);
const duration = getAudioDuration(outputPath, ffmpegPath);
const result = {
file: outputPath,
duration: Math.round(duration * 10) / 10,
size: stats.size,
captions: captions.length,
};
if (warnings.length) result.warnings = warnings;
return result;
} finally {
// Cleanup temp directory
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
}
}
@@ -0,0 +1,175 @@
// web-test recording/tts v1.17 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { execFileSync, spawn } from 'child_process';
import { existsSync as fsExistsSync, writeFileSync } from 'fs';
import { resolve as pathResolve } from 'path';
import { pathToFileURL } from 'url';
import { projectRoot } from '../core/state.mjs';
/** Resolve ffmpeg binary path. */
export function resolveFfmpeg(explicit) {
// 1. Explicit path
if (explicit) {
try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
}
// 2. FFMPEG_PATH env var
const envPath = process.env.FFMPEG_PATH;
if (envPath) {
try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
catch { /* fall through */ }
}
// 3. System PATH
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
catch { /* fall through */ }
// 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
if (fsExistsSync(localPath)) {
try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
catch { /* fall through */ }
}
// 5. Error with instructions
throw new Error(
'ffmpeg not found. Install it:\n' +
' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
' - Or pass ffmpegPath option to startRecording()'
);
}
// ── TTS providers ──────────────────────────────────────────────────────────
/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
let _edgeTtsModule = null;
export async function resolveEdgeTts() {
if (_edgeTtsModule) return _edgeTtsModule;
// 1. Global/project-level install (standard Node resolution)
try {
_edgeTtsModule = await import('node-edge-tts');
return _edgeTtsModule;
} catch { /* fall through */ }
// 2. tools/tts/ relative to project root
const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
if (fsExistsSync(localPath)) {
try {
_edgeTtsModule = await import(pathToFileURL(localPath).href);
return _edgeTtsModule;
} catch { /* fall through */ }
}
// 3. Error with instructions
throw new Error(
'node-edge-tts not found. Install it:\n' +
' - npm install --prefix tools/tts node-edge-tts\n' +
' - or: npm install node-edge-tts (global/project-level)'
);
}
/**
* Edge TTS provider (free, no API key). Uses node-edge-tts package.
* @param {string} text text to synthesize
* @param {string} outputPath path for the output mp3 file
* @param {object} opts { voice }
*/
export async function edgeTtsProvider(text, outputPath, opts = {}) {
const { EdgeTTS } = await resolveEdgeTts();
const voice = opts.voice || 'ru-RU-DmitryNeural';
const tts = new EdgeTTS({ voice });
await Promise.race([
tts.ttsPromise(text, outputPath),
new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
]);
}
/**
* OpenAI-compatible TTS provider. Requires apiKey.
* @param {string} text text to synthesize
* @param {string} outputPath path for the output mp3 file
* @param {object} opts { apiKey, apiUrl, voice, model }
*/
export async function openaiTtsProvider(text, outputPath, opts = {}) {
const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
const resp = await fetch(apiUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: opts.model || 'tts-1',
input: text,
voice: opts.voice || 'alloy',
response_format: 'mp3',
}),
});
if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
const buf = Buffer.from(await resp.arrayBuffer());
writeFileSync(outputPath, buf);
}
/**
* ElevenLabs TTS provider. Requires apiKey.
* @param {string} text text to synthesize
* @param {string} outputPath path for the output mp3 file
* @param {object} opts { apiKey, apiUrl, voice, model }
*/
export async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
const resp = await fetch(apiUrl, {
method: 'POST',
headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
model_id: opts.model || 'eleven_multilingual_v2',
}),
});
if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
const buf = Buffer.from(await resp.arrayBuffer());
writeFileSync(outputPath, buf);
}
/** Get TTS provider function by name. */
export function getTtsProvider(name) {
switch (name) {
case 'openai': return openaiTtsProvider;
case 'elevenlabs': return elevenlabsTtsProvider;
case 'edge': default: return edgeTtsProvider;
}
}
// ── TTS audio helpers ──────────────────────────────────────────────────────
/**
* Get audio duration in seconds using ffprobe.
* @param {string} filePath path to audio file
* @param {string} ffmpegPath path to ffmpeg binary (ffprobe is found next to it)
* @returns {number} duration in seconds
*/
export function getAudioDuration(filePath, ffmpegPath) {
const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
const out = execFileSync(ffprobePath, [
'-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
], { encoding: 'utf8', timeout: 10000 }).trim();
return parseFloat(out) || 0;
}
/**
* Generate a silence mp3 file of given duration.
* @param {string} outputPath path for the output mp3 file
* @param {number} seconds duration in seconds
* @param {string} ffmpegPath path to ffmpeg binary
*/
export function generateSilence(outputPath, seconds, ffmpegPath) {
execFileSync(ffmpegPath, [
'-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
'-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
], { stdio: 'pipe', timeout: 10000 });
}
@@ -0,0 +1,561 @@
// web-test spreadsheet v1.20 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, ensureConnected } from '../core/state.mjs';
import { detectFormScript } from '../../dom.mjs';
import { waitForStable } from '../core/wait.mjs';
import { getFormState } from '../forms/state.mjs';
import { returnFormState } from '../core/helpers.mjs';
import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs';
import { checkForErrors } from '../core/errors.mjs';
// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) ---
/**
* Scan spreadsheet iframes for the current form and collect all cells.
* Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> }
* where frameIndex is the Playwright frames[] index (1-based, 0 = main).
*/
async function scanSpreadsheetCells(formNum) {
const prefix = `form${formNum ?? 0}_`;
const iframeHandles = await page.$$('iframe');
const allCells = new Map();
const frameMap = new Map(); // key 'r_c' → Playwright Frame object
for (const handle of iframeHandles) {
const ok = await handle.evaluate((f, pfx) => {
if (f.offsetWidth < 100) return false;
let el = f.parentElement;
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
if (el.id && el.id.startsWith(pfx)) return true;
}
return false;
}, prefix);
if (!ok) continue;
const frame = await handle.contentFrame();
if (!frame) continue;
try {
const cells = await frame.evaluate(`(() => {
const cells = [];
document.querySelectorAll('div[x]').forEach(d => {
const span = d.querySelector('span');
const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || '';
if (!text) return;
const rowDiv = d.parentElement;
const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null;
const col = d.getAttribute('x');
if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text });
});
return cells;
})()`);
for (const cell of cells) {
const key = `${cell.r}_${cell.c}`;
if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) {
allCells.set(key, cell);
frameMap.set(key, frame);
}
}
} catch { /* skip inaccessible frames */ }
}
return { allCells, frameMap };
}
/**
* Build structured mapping from raw cells: headers, column map, data/totals row indices.
* Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap }
* or null if header detection fails.
*/
function buildSpreadsheetMapping(allCells) {
const rowMap = new Map();
let maxCol = 0;
for (const cell of allCells.values()) {
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
rowMap.get(cell.r).set(cell.c, cell.t);
if (cell.c > maxCol) maxCol = cell.c;
}
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
const rows = sortedRows.map(r => {
const cm = rowMap.get(r);
const arr = [];
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
return arr;
});
// Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78"
// Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification.
const isNumericVal = (c) => {
if (!c || !/\d/.test(c)) return false;
const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, '');
return /^\d[\d,]*$/.test(s);
};
// Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus).
// Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51".
const isDataNumericVal = (c) => {
if (!isNumericVal(c)) return false;
return /[\s\u00a0,]/.test(c) || /^-/.test(c);
};
const hasNumber = (row) => row.some(c => isNumericVal(c));
const nonEmpty = (row) => row.filter(c => c !== '').length;
// Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx.
// Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor).
const buildRichMapping = (detailIdx, firstDataIdx) => {
let groupIdx = -1;
if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1;
const detailRow = rows[detailIdx];
const groupRow = groupIdx >= 0 ? rows[groupIdx] : null;
// Detect optional third header level above group row (bounds carry-forward)
let superRow = null;
if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) {
superRow = rows[groupIdx - 1];
}
// Build column names (group + detail merge)
const groupFilled = new Array(maxCol + 1).fill('');
if (groupRow) {
let cur = '';
for (let c = 0; c <= maxCol; c++) {
if (groupRow[c]) {
cur = groupRow[c];
} else if (superRow && superRow[c]) {
// New top-level header starts here — stop carry-forward
cur = '';
}
groupFilled[c] = cur;
}
}
const detailCounts = {};
for (let c = 0; c <= maxCol; c++) {
const n = detailRow[c];
if (n) detailCounts[n] = (detailCounts[n] || 0) + 1;
}
// Detect DCS column codes (К1, К2, ...) — always prefix with group when present
const detailNonEmpty = detailRow.filter(c => c);
const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c));
const colNames = [];
for (let c = 0; c <= maxCol; c++) {
const detail = detailRow[c];
const group = groupFilled[c];
const sup = superRow ? superRow[c] : '';
if (detail) {
// Prefer group prefix; fall back to superRow for DCS code columns without sub-group
const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : '');
const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === ''));
colNames.push(needPrefix ? `${prefix} / ${detail}` : detail);
} else if (group) {
colNames.push(group);
} else if (sup) {
colNames.push(sup);
} else {
colNames.push(null);
}
}
const colMap = new Map();
for (let c = 0; c < colNames.length; c++) {
if (colNames[c]) colMap.set(colNames[c], c);
}
// Classify data rows: separate data indices and totals index
const dataRowIndices = [];
let totalsRowIdx = -1;
for (let i = firstDataIdx; i < rows.length; i++) {
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
const first = rows[i][0]?.trim().toLowerCase();
if (first === 'итого' || first === 'всего') {
totalsRowIdx = i;
} else {
dataRowIndices.push(i);
}
}
const superRowIdx = superRow ? groupIdx - 1 : -1;
return {
rows, sortedRows, maxCol, colNames, colMap,
headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx,
dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx,
rowMap, hasNumber, nonEmpty,
};
};
// --- Level 1: DCS-code row anchor ---
// ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker.
// Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб").
for (let i = 0; i < rows.length; i++) {
const detailNonEmpty = rows[i].filter(c => c);
if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) {
// Find first non-empty row after the К-codes row as data start
let firstDataIdx = rows.length;
for (let j = i + 1; j < rows.length; j++) {
if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; }
}
return buildRichMapping(i, firstDataIdx);
}
}
// --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) ---
let firstDataIdx = rows.length;
for (let i = 0; i < rows.length; i++) {
if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; }
}
if (firstDataIdx === rows.length) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; }
}
}
if (firstDataIdx < rows.length) {
let detailIdx = -1;
for (let i = firstDataIdx - 1; i >= 0; i--) {
if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; }
}
if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx);
}
// --- Level 3: single-row header fallback (text-only data, query console) ---
// First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition.
let headerIdx = -1;
for (let i = 0; i < rows.length; i++) {
if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; }
}
// Single-column tables: accept nonEmpty >= 1
if (headerIdx === -1 && maxCol === 0) {
for (let i = 0; i < rows.length; i++) {
if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; }
}
}
if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total }
const detailRow = rows[headerIdx];
const colNames = [];
for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null);
const colMap = new Map();
for (let c = 0; c < colNames.length; c++) {
if (colNames[c]) colMap.set(colNames[c], c);
}
const dataRowIndices = [];
let totalsRowIdx = -1;
for (let i = headerIdx + 1; i < rows.length; i++) {
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
const first = rows[i][0]?.trim().toLowerCase();
if (first === 'итого' || first === 'всего') {
totalsRowIdx = i;
} else {
dataRowIndices.push(i);
}
}
return {
rows, sortedRows, maxCol, colNames, colMap,
headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1,
dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx,
rowMap, hasNumber, nonEmpty,
};
}
/**
* Scroll SpreadsheetDocument to make a cell visible using arrow keys.
* Uses native platform scroll keeps headers, data, and scrollbar synchronized.
*
* How it works:
* 1. Check target cell visibility via Playwright boundingBox (page-level coords).
* 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay.
* This is the same native click that clickSpreadsheetCell uses it gives keyboard
* focus to the spreadsheet and keeps headers/data/scrollbar in sync.
* (frame.locator().click() bypasses overlay desyncs frozen headers;
* page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.)
* 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport.
*
* @param {Frame} frame - Playwright Frame containing the spreadsheet cells
* @param {number} physRow - physical row (y attribute) in the frame
* @param {number} physCol - physical column (x attribute) in the frame
* @param {Locator} cellLoc - Playwright locator for the target cell (from caller)
*/
async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) {
const pageVw = await page.evaluate('window.innerWidth');
// Get iframe bounds — the actual visible region on page.
// The iframe may extend behind the section panel on the left, so cells with
// x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel.
const frameElm = await frame.frameElement();
const frameBox = await frameElm.boundingBox();
const visLeft = frameBox ? frameBox.x : 0;
const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw;
const getBox = async () => {
try { return await cellLoc.boundingBox({ timeout: 500 }); }
catch { return null; }
};
const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight;
let box = await getBox();
if (!box) return; // cell not in DOM
if (isFullyVisible(box)) return;
const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft';
// Find a fully-visible cell to click for focus.
// Prefer cells in the target row (scrollable area), fall back to any row.
const targetRowSel = `div[y="${physRow}"] div[x]`;
const anyRowSel = 'div[x]';
let focusClicked = false;
for (const sel of [targetRowSel, anyRowSel]) {
const locs = frame.locator(sel);
const count = await locs.count();
const candidates = [];
for (let ci = 0; ci < count; ci++) {
const b = await locs.nth(ci).boundingBox();
if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) {
candidates.push({ ci, box: b });
}
}
if (candidates.length === 0) continue;
candidates.sort((a, b) => a.box.x - b.box.x);
// ArrowRight → rightmost fully-visible (each press scrolls right immediately)
// ArrowLeft → leftmost fully-visible (each press scrolls left immediately)
const pick = direction === 'ArrowRight'
? candidates[candidates.length - 1]
: candidates[0];
// Native click through overlay — gives keyboard focus + no header desync.
await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2);
await page.waitForTimeout(100);
focusClicked = true;
break;
}
if (!focusClicked) return; // no visible cells — can't scroll
await scrollHorizontallyByKey({
page, direction,
isFullyVisible: async () => {
const b = await getBox();
return !!b && isFullyVisible(b);
},
getCenterX: async () => {
const b = await getBox();
return b ? b.x + b.width / 2 : null;
},
});
}
/**
* Click a cell in SpreadsheetDocument by logical coordinates.
* target: { row: number|'totals'|{colName: value}, column: string }
* Internal helper called from clickElement when first arg is an object.
*/
export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) {
ensureConnected();
const formNum = await page.evaluate(detectFormScript());
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.');
const mapping = buildSpreadsheetMapping(allCells);
if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.');
const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping;
// Resolve column (exact → endsWith " / X" → includes)
let colName = target.column;
if (!colMap.has(colName)) {
const available = colNames.filter(n => n);
const suffix = ' / ' + colName;
const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName));
if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`);
colName = match;
}
const physCol = colMap.get(colName);
// Resolve row → index into rows[] array
let rowIdx;
const row = target.row;
if (row === 'totals') {
if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.');
rowIdx = totalsRowIdx;
} else if (typeof row === 'number') {
if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`);
rowIdx = dataRowIndices[row];
} else if (typeof row === 'object') {
// Filter: { colName: value } — find first data row where column matches
const filterEntries = Object.entries(row);
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
const resolveCol = (name) => {
if (colMap.has(name)) return colMap.get(name);
const suffix = ' / ' + name;
const available = colNames.filter(n => n);
const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name));
return m ? colMap.get(m) : null;
};
rowIdx = dataRowIndices.find(i => {
return filterEntries.every(([fCol, fVal]) => {
const fColIdx = resolveCol(fCol);
if (fColIdx == null) return false;
const cellText = norm(rows[i][fColIdx]);
const search = norm(fVal);
return cellText === search || cellText.includes(search);
});
});
if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`);
} else {
throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.');
}
// Map rows[] index → physical row number
const physRow = sortedRows[rowIdx];
const cellKey = `${physRow}_${physCol}`;
const frame = frameMap.get(cellKey);
if (!frame) {
// Cell exists in mapping but might be empty — try clicking anyway
throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`);
}
// Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs.
const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first();
// Scroll cell into view using arrow keys — the only reliable way to scroll
// 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar.
await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv);
const box = await cellDiv.boundingBox();
if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`);
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
if (modKey) await page.keyboard.down(modKey);
if (dbl) {
await page.mouse.dblclick(x, y);
} else {
await page.mouse.click(x, y);
}
if (modKey) await page.keyboard.up(modKey);
await waitForStable();
return returnFormState({ clicked: { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) } });
}
/**
* Search spreadsheet iframes for a cell matching text (for text fallback in clickElement).
* Returns { frameIndex, physRow, physCol, box } or null if not found.
*/
export async function findSpreadsheetCellByText(formNum, searchText) {
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
if (allCells.size === 0) return null;
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
const target = norm(searchText);
// Exact match first, then includes
let found = null;
for (const [key, cell] of allCells) {
if (norm(cell.t) === target) { found = { key, cell }; break; }
}
if (!found) {
for (const [key, cell] of allCells) {
if (norm(cell.t).includes(target)) { found = { key, cell }; break; }
}
}
if (!found) return null;
const frame = frameMap.get(found.key);
if (!frame) return null;
// Scroll cell into view using native arrow-key mechanism
const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first();
await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv);
const box = await cellDiv.boundingBox();
if (!box) return null;
return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box };
}
/**
* Read report output (SpreadsheetDocumentField) rendered in iframes.
* 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes.
* Each cell is a div[x] inside a row div[y], text content in <span>.
*
* Returns structured data:
* { title, headers, data: [{col: val}], totals: {col: val}, total }
* If header detection fails, falls back to { rows: string[][], total }.
*/
export async function readSpreadsheet() {
ensureConnected();
const formNum = await page.evaluate(detectFormScript());
const { allCells } = await scanSpreadsheetCells(formNum);
if (allCells.size === 0) {
// Check for state window messages (info bar) that explain why the report is empty
const err = await checkForErrors();
const hint = err?.stateText?.length ? err.stateText.join('; ') : '';
throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.'));
}
const mapping = buildSpreadsheetMapping(allCells);
if (!mapping) {
// Fallback: return raw rows
const rowMap = new Map();
let maxCol = 0;
for (const cell of allCells.values()) {
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
rowMap.get(cell.r).set(cell.c, cell.t);
if (cell.c > maxCol) maxCol = cell.c;
}
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
const rows = sortedRows.map(r => {
const cm = rowMap.get(r);
const arr = [];
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
return arr;
});
return { rows, total: rows.length };
}
const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping;
// Convert data rows to objects
const data = [];
let totals = null;
const toObj = (row) => {
const obj = {};
for (let c = 0; c < colNames.length; c++) {
if (colNames[c] && row[c]) obj[colNames[c]] = row[c];
}
return obj;
};
for (let i = dataStartIdx; i < rows.length; i++) {
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
const first = rows[i][0]?.trim().toLowerCase();
if (first === 'итого' || first === 'всего') {
totals = toObj(rows[i]);
} else {
data.push(toObj(rows[i]));
}
}
// Meta: title, params, filters from rows before header (superRow is part of header, not meta)
const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx);
let title = '';
const meta = [];
for (let i = 0; i < metaEnd; i++) {
const parts = rows[i].filter(c => c);
if (!parts.length) continue;
if (!title) { title = parts.join(' '); continue; }
meta.push(parts.join(' '));
}
return {
title: title || undefined,
meta: meta.length ? meta : undefined,
headers: colNames.filter(n => n),
data,
totals: totals || undefined,
total: data.length,
};
}
@@ -0,0 +1,235 @@
// web-test table/click-cell v1.4 — click a cell in a form grid by (row, column).
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Routed from core/click.mjs when the user calls clickElement({row, column}) and
// the form has no SpreadsheetDocument (or `table` matches a grid).
//
// Key behaviors:
// - `row` can be a number (index in current DOM window) or `{col: value}` filter.
// - `scroll: true | number` enables reveal-loop via PageDown when a filter row
// isn't visible. End detected by snapshot stability between PageDowns.
// - Horizontal scroll mirrors SpreadsheetDocument: focus a visible cell in the
// target row, press ArrowRight/Left until the target column is in viewport.
//
// 1С virtualization quirks worth knowing:
// - DOM holds a window of ~N visible rows. PageDown's first press moves the
// cursor inside the window; subsequent presses swap the window contents.
// - scrollTop/scrollLeft are always 0; absolute X of cells shifts on horizontal
// scroll. So scroll progress must be inferred from cell coordinates / snapshot
// diffs, never from scrollTop/Height.
// - Frozen columns (.gridBoxFix) stay pinned at the left, overlap with scrolled
// cells — DOM scripts handle the partition; engine just consumes their results.
import { page } from '../core/state.mjs';
import { waitForStable } from '../core/wait.mjs';
import { modifierClick, returnFormState, isInputFocusedInGrid } from '../core/helpers.mjs';
import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs';
import {
findGridCellScript, findFocusCellScript, snapshotGridScript,
} from '../../dom.mjs';
const REVEAL_DEFAULT_LIMIT = 50;
const PD_WAIT_MS = 300;
const FOCUS_WAIT_MS = 150;
/**
* Guard: a 'pic:N' filter value is a readTable picture token, not real cell text.
* Picture cells render an icon (no text), so they can't select a row fail fast
* with guidance instead of a confusing 'row_not_found'.
*/
function assertNotPictureFilter(filter) {
for (const [k, v] of Object.entries(filter)) {
if (typeof v === 'string' && /^pic:\d+$/.test(v.trim())) {
throw new Error(`clickElement: "${v}" is a readTable picture value (column "${k}"), not selectable text — it can't be used as a row filter. Filter by a text column (e.g. name/number) instead.`);
}
}
}
/**
* Resolve a `{ col: value }` row filter to a numeric index into the grid's current
* DOM window (`body.querySelectorAll('.gridLine')`). Reused by fillTableRow so it
* can target an existing row by cell values, mirroring clickElement.
*
* The filter matches across ALL columns (AND). `findGridCellScript` requires a
* `column`, so we pass the first filter key as a placeholder it only affects the
* returned coordinates (which we ignore), not row selection. The matched row
* guarantees that key's cell is in the DOM, so no `cell_not_in_dom` for it.
*
* @param {object} args
* @param {number} args.formNum
* @param {string} [args.gridSelector] - CSS selector for the target grid (same grid the caller edits)
* @param {object} args.filter - `{ col: value }` (one or more columns)
* @param {string} [args.gridName] - for diagnostics in error messages
* @param {boolean|number} [args.scroll] - reveal-loop beyond the DOM window (true = 50 PageDowns, number = limit)
* @returns {Promise<number>} resolved row index
*/
export async function resolveRowIndexByFilter({ formNum, gridSelector, filter, gridName, scroll }) {
assertNotPictureFilter(filter);
const target = { row: filter, column: Object.keys(filter)[0] };
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
if (cell?.error === 'row_not_found' && scroll) {
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
}
if (cell?.error) throw cellError(cell, target, gridName, scroll, 'fillTableRow');
return cell.rowIdx;
}
/**
* Click a cell in a form grid by (row, column). Called from core/click.mjs.
*
* @param {object} target - { row: number|{col:value}, column: string }
* @param {object} ctx
* @param {number} ctx.formNum
* @param {string} ctx.gridSelector - CSS selector for the target grid
* @param {string} [ctx.gridName] - for diagnostics
* @param {string} [ctx.modifier] - 'ctrl' | 'shift' for multi-select
* @param {boolean} [ctx.dblclick]
* @param {boolean|number} [ctx.scroll] - true = up to 50 PageDowns, number = exact limit
*/
export async function clickGridCell(target, ctx) {
const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx;
if (target?.row && typeof target.row === 'object') assertNotPictureFilter(target.row);
// 1. Try to find the cell in current DOM window.
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
// 2. Reveal loop: only for filter-based row search with scroll opt-in.
if (cell?.error === 'row_not_found' && scroll && target.row && typeof target.row === 'object') {
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
}
if (cell?.error) throw cellError(cell, target, gridName, scroll);
// 3. Horizontal scroll if cell is off-viewport.
if (!cell.visible) {
await scrollGridToCell({ formNum, gridSelector, target, cell });
cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
if (cell?.error) {
throw new Error(`clickElement: cell vanished after horizontal scroll: ${cell.error}`);
}
if (!cell.visible) {
// Scroll loop bailed out before reaching the target. Don't silently click
// at off-screen coordinates — that would report a false success.
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
throw new Error(`clickElement: horizontal scroll could not reach column "${cell.columnText}"${ctxMsg} (cell still at x=${cell.cellX}, viewport ends at ${cell.gridRight}).`);
}
}
// 4. Click.
await modifierClick(cell.x, cell.y, modifier, { dbl: !!dblclick });
await waitForStable();
return returnFormState({
clicked: {
kind: 'gridCell',
row: target.row,
column: cell.columnText,
...(dblclick ? { dblclick: true } : {}),
...(modifier ? { modifier } : {}),
},
});
}
function cellError(cell, target, gridName, scroll, who = 'clickElement') {
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
if (cell.error === 'row_not_found') {
const hint = scroll
? ' (reveal-loop exhausted)'
: ' — pass { scroll: true } to scan beyond the current DOM window';
return new Error(`${who}: row matching ${JSON.stringify(target.row)} not found${ctxMsg}${hint}.`);
}
if (cell.error === 'column_not_found' || cell.error === 'filter_column_not_found') {
return new Error(`${who}: column "${cell.column}" not found${ctxMsg}. Available: ${(cell.available || []).join(', ')}`);
}
if (cell.error === 'row_out_of_range') {
return new Error(`${who}: row index ${cell.row} out of range${ctxMsg} (loaded: ${cell.loaded}). Note: row index is into current DOM window, not absolute — long lists are virtualized.`);
}
return new Error(`${who}: cannot resolve cell ${JSON.stringify(target)}${ctxMsg}: ${cell.error}`);
}
/**
* Press PageDown in a loop, scanning DOM each iteration for the target row.
* Bail when the row is found, snapshots stop changing (end of list), or limit hit.
* page.mouse.click on a safe cell first PageDown needs keyboard focus on gridBody.
*/
async function revealAndFindCell({ formNum, gridSelector, target, scroll }) {
const limit = typeof scroll === 'number' ? scroll : REVEAL_DEFAULT_LIMIT;
const focusPt = await page.evaluate(findFocusCellScript(gridSelector));
if (!focusPt) return { error: 'no_focusable_cell' };
await page.mouse.click(focusPt.x, focusPt.y);
await page.waitForTimeout(FOCUS_WAIT_MS);
// Click on a Number/Date cell auto-enters edit mode in 1С; PageDown there
// is a no-op. Exit edit mode before driving the reveal loop.
if (await isInputFocusedInGrid({ gridSelector })) {
await page.keyboard.press('Escape');
await page.waitForTimeout(150);
}
let prevSnap = await page.evaluate(snapshotGridScript(gridSelector));
for (let i = 0; i < limit; i++) {
await page.keyboard.press('PageDown');
await page.waitForTimeout(PD_WAIT_MS);
const cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
if (!cell?.error) return cell;
const snap = await page.evaluate(snapshotGridScript(gridSelector));
// Reached the end of the list. Primary signal: nothing remains below
// (`hasBelow === false`) — the reliable cross-grid-type signal. Content
// stability is only a fallback when hasBelow is unknown: it compares the
// full-row text (snapshotGridScript joins every cell), so a low-cardinality
// first column (e.g. all "Товар 0X") can't look "stable" mid-scroll.
const reachedEnd = snap && (
snap.hasBelow === false
|| (snap.hasBelow == null
&& snap.firstText === prevSnap?.firstText
&& snap.lastText === prevSnap?.lastText
&& snap.selIdx === prevSnap?.selIdx
&& snap.lineCount === prevSnap?.lineCount)
);
if (reachedEnd) return { error: 'row_not_found', filter: target.row };
prevSnap = snap;
}
return { error: 'row_not_found', filter: target.row };
}
/**
* Scroll the grid horizontally so the target cell falls inside the viewport.
* Focuses an edge cell in the target row (rightmost-visible for ArrowRight,
* leftmost-visible for ArrowLeft) so the next arrow key immediately scrolls.
*
* Frozen columns (gridBoxFix) are excluded from focus candidates they don't
* drive the scrollable viewport. The DOM script handles that detail.
*/
async function scrollGridToCell({ formNum, gridSelector, target, cell }) {
const direction = cell.cellX > cell.gridRight ? 'ArrowRight'
: cell.cellRight < cell.gridX ? 'ArrowLeft'
: (cell.cellRight > cell.gridRight ? 'ArrowRight' : 'ArrowLeft');
const focusPt = await page.evaluate(
findFocusCellScript(gridSelector, { rowIdx: cell.rowIdx, direction })
);
if (!focusPt) throw new Error('clickElement: no visible cell to focus for horizontal scroll');
await page.mouse.click(focusPt.x, focusPt.y);
await page.waitForTimeout(FOCUS_WAIT_MS);
// Click on a Number/Date cell auto-enters edit mode in 1С; arrow keys there
// navigate text inside the input rather than scrolling the viewport. Exit first.
if (await isInputFocusedInGrid({ gridSelector })) {
await page.keyboard.press('Escape');
await page.waitForTimeout(150);
}
await scrollHorizontallyByKey({
page,
direction,
isFullyVisible: async () => {
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
return !!c && !c.error && c.visible;
},
getCenterX: async () => {
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
return c && !c.error ? c.x : null;
},
});
}
@@ -0,0 +1,95 @@
// web-test table/click-row v1.0 — click handlers for grid row targets: gridGroup, gridTreeNode, gridRow.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// All handlers are called by core/click.mjs dispatcher after target is found.
// Each takes (target, ctx) where ctx = { formNum, modifier, dblclick, toggle, expand, ... }
// and returns a form state with `clicked: { kind, name, ... }`.
import { waitForStable } from '../core/wait.mjs';
import { modifierClick, returnFormState } from '../core/helpers.mjs';
import { getGridToggleIcon, shouldClickToggle } from './grid-toggle.mjs';
/**
* Click handler for gridGroup / gridParent targets (hierarchy mode).
* With `expand`/`toggle` click the level-indicator icon to expand/collapse the group.
* Without dblclick the row to enter the group / go up to parent.
*/
export async function clickGridGroupTarget(target, ctx) {
const { formNum, modifier, toggle, expand } = ctx;
if (expand != null || toggle) {
// Expand/collapse group — click the triangle icon (.gridListH/.gridListV).
// expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click.
const levelIconInfo = await getGridToggleIcon(target, formNum, {
iconSelector: '.gridListH, .gridListV',
isExpandedExpr: "icon.classList.contains('gridListV')",
});
const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle);
if (shouldClick) {
if (levelIconInfo) {
await modifierClick(levelIconInfo.x, levelIconInfo.y, modifier);
} else {
// Fallback: dblclick (standard hierarchy navigation)
await modifierClick(target.x, target.y, modifier, { dbl: true });
}
}
await waitForStable(formNum);
return returnFormState({
clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.',
});
}
// Default: dblclick to enter group / go up to parent
await modifierClick(target.x, target.y, modifier, { dbl: true });
await waitForStable(formNum);
return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } });
}
/**
* Click handler for gridTreeNode targets (tree-style grid).
* With `expand`/`toggle` click the tree icon to expand/collapse.
* Without single-click to select the row (no expand).
*/
export async function clickGridTreeNodeTarget(target, ctx) {
const { formNum, modifier, toggle, expand } = ctx;
if (expand != null || toggle) {
// Expand/collapse tree node — click the tree icon [tree="true"].
const treeIconInfo = await getGridToggleIcon(target, formNum, {
iconSelector: '.gridBoxImg [tree="true"]',
isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")',
});
const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle);
if (shouldClick) {
if (treeIconInfo) {
await modifierClick(treeIconInfo.x, treeIconInfo.y, modifier);
} else {
// Fallback: dblclick on row (works for trees without clickable +/- icons)
await modifierClick(target.x, target.y, modifier, { dbl: true });
}
}
await waitForStable(formNum);
return returnFormState({
clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.',
});
}
// Default: select row (click text, no expand/collapse)
await modifierClick(target.x, target.y, modifier);
await waitForStable(formNum);
return returnFormState({
clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) },
hint: 'Row selected. Use { expand: true } to expand/collapse.',
});
}
/**
* Click handler for gridRow targets (flat list row).
* Single click selects the row; `dblclick: true` opens the item.
*/
export async function clickGridRowTarget(target, ctx) {
const { modifier, dblclick } = ctx;
await modifierClick(target.x, target.y, modifier, { dbl: !!dblclick });
await waitForStable();
return returnFormState({
clicked: { kind: 'gridRow', name: target.name, ...(dblclick ? { dblclick: true } : {}), ...(modifier ? { modifier } : {}) },
});
}
@@ -0,0 +1,248 @@
// web-test table/filter v1.19 — filterList / unfilterList — simple search + advanced-column filter badges.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs';
import {
detectFormScript, readSubmenuScript,
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
findFirstGridCellCoordsScript, findColumnFirstCellCoordsScript,
readFieldSelectorInfoScript, pickFieldInSelectorDropdownScript,
readFilterDialogInfoScript, findFilterBadgeCloseScript, findFirstFilterBadgeCloseScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import { safeClick, returnFormState } from '../core/helpers.mjs';
import { selectValue, fillReferenceField } from '../forms/select-value.mjs';
import { pasteText } from '../core/clipboard.mjs';
import { getFormState } from '../forms/state.mjs';
import { clickElement } from '../core/click.mjs';
/**
* Filter the current list by field value, or search via search bar.
*
* Without field: simple search via the search bar (filters by all columns, no badge).
* With field: advanced search clicks target column cell to auto-populate FieldSelector,
* opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge.
* Handles text, reference (with Tab autocomplete), and date fields automatically.
* Multiple filters can be chained by calling filterList multiple times.
*
* @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016")
* @param {object} [opts]
* @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата")
* @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact)
*/
export async function filterList(text, { field, exact } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('filterList: no form found');
if (!field) {
// --- Simple search: fill search input + Enter ---
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
if (searchInfo) {
await page.click(`[id="${searchInfo.id}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await waitForStable(formNum);
return returnFormState({ filtered: { type: 'search', text } });
}
// No search input — Ctrl+F opens advanced search on such forms.
// Click first grid cell then fall through to advanced search path below.
const firstCell = await page.evaluate(findFirstGridCellCoordsScript(formNum));
if (!firstCell) throw new Error('filterList: no search input and no grid found on this form');
await page.mouse.click(firstCell.x, firstCell.y);
await page.waitForTimeout(300);
field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected)
}
// --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти ---
// Clicking a cell in the target column makes it active, so when Alt+F opens the
// advanced search dialog, FieldSelector is auto-populated with the correct field name.
// This avoids changing FieldSelector programmatically (which can cause errors).
const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim());
// 1. Click a cell in the target column to activate it (auto-populates FieldSelector).
// If the column isn't visible in the grid, click any cell and use DLB fallback later.
let needDlb = false;
const gridEl = await page.evaluate(findColumnFirstCellCoordsScript(formNum, field));
if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`);
needDlb = !!gridEl.needDlb;
await page.mouse.click(gridEl.x, gridEl.y);
await page.waitForTimeout(500);
// 2. Open advanced search dialog via Alt+F (with fallback to Еще menu)
await page.keyboard.press('Alt+f');
await page.waitForTimeout(2000);
let dialogForm = await page.evaluate(detectFormScript());
if (dialogForm === formNum) {
// Alt+F didn't open dialog — fallback to Еще → Расширенный поиск
await clickElement('Еще');
await page.waitForTimeout(500);
const menu = await page.evaluate(readSubmenuScript());
const searchItem = Array.isArray(menu) && menu.find(i =>
i.name.replace(/ /g, ' ').toLowerCase().includes('расширенный поиск'));
if (!searchItem) {
await page.keyboard.press('Escape');
throw new Error('filterList: advanced search dialog could not be opened');
}
await page.mouse.click(searchItem.x, searchItem.y);
await page.waitForTimeout(2000);
dialogForm = await page.evaluate(detectFormScript());
if (dialogForm === formNum) {
throw new Error('filterList: advanced search dialog did not open');
}
}
// 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown
// Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field)
if (needDlb && field) {
const fsInfo = await page.evaluate(readFieldSelectorInfoScript(dialogForm));
if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) {
await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY);
await page.waitForTimeout(1500);
const ddResult = await page.evaluate(pickFieldInSelectorDropdownScript(field));
if (ddResult.error) {
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`);
}
await page.mouse.click(ddResult.x, ddResult.y);
await page.waitForTimeout(3000);
}
}
// 3. Read dialog state and fill Pattern
// Detect field type by Pattern's sibling buttons:
// - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value)
// - iDLB on Pattern → reference field (paste + Tab for autocomplete)
// - neither → plain text field (just paste)
const dialogInfo = await page.evaluate(readFilterDialogInfoScript(dialogForm));
if (dialogInfo.isDate) {
// Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste)
if (isDateValue && dialogInfo.patternValue !== text.trim()) {
await page.click(`[id="${dialogInfo.patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Home');
await page.waitForTimeout(100);
await page.keyboard.press('Shift+End');
await page.waitForTimeout(100);
await pasteText(text);
await page.waitForTimeout(500);
}
} else {
// Text or reference field: fill Pattern via clipboard paste
await page.click(`[id="${dialogInfo.patternId}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(300);
if (dialogInfo.isRef) {
// Reference field: Tab triggers autocomplete to resolve text → reference value
await page.keyboard.press('Tab');
await page.waitForTimeout(2000);
}
}
// 3b. Switch CompareType if exact match requested (text fields only).
// Date/number: always exact, CompareType disabled. Reference: default exact (selects ref).
if (exact && !dialogInfo.isDate && !dialogInfo.isRef) {
const exactRadio = await page.evaluate(findCompareTypeRadioScript(dialogForm, 2));
if (exactRadio && !exactRadio.already) {
await page.mouse.click(exactRadio.x, exactRadio.y);
await page.waitForTimeout(300);
}
}
// 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked)
const findBtnCoords = await page.evaluate(findNamedButtonScript('Найти'));
if (findBtnCoords) {
await page.mouse.click(findBtnCoords.x, findBtnCoords.y);
} else {
await clickElement('Найти');
}
await page.waitForTimeout(2000);
// 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
// Check the specific dialog form — not generic modalSurface — to avoid closing parent modals
// (e.g. a selection form that opened this advanced search).
for (let attempt = 0; attempt < 3; attempt++) {
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
if (!dialogVisible) break;
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
await waitForStable(formNum);
return returnFormState({ filtered: { type: 'advanced', field, text, exact: !!exact } });
}
/**
* Remove active filters/search from the current list.
*
* Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field).
* With field: clicks the × button on the specific filter badge (selective removal).
*
* @param {object} [opts]
* @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×)
*/
export async function unfilterList({ field } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('unfilterList: no form found');
if (field) {
// --- Selective: click × on specific filter badge ---
const closeBtn = await page.evaluate(findFilterBadgeCloseScript(formNum, field));
if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`);
await page.mouse.click(closeBtn.x, closeBtn.y);
await waitForStable(formNum);
return returnFormState({ unfiltered: { field: closeBtn.field } });
}
// --- Clear ALL filters ---
// 1. Remove all advanced filter badges (.trainItem × buttons)
for (let attempt = 0; attempt < 20; attempt++) {
const badge = await page.evaluate(findFirstFilterBadgeCloseScript(formNum));
if (!badge) break;
await page.mouse.click(badge.x, badge.y);
await waitForStable(formNum);
}
// 2. Cancel active search via Ctrl+Q
await page.keyboard.press('Control+q');
await waitForStable(formNum);
// 3. Clear simple search field if it has a value
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
if (searchInfo?.value) {
await page.click(`[id="${searchInfo.id}"]`);
await page.waitForTimeout(200);
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.keyboard.press('Enter');
await waitForStable(formNum);
}
return returnFormState({ unfiltered: true });
}
@@ -0,0 +1,64 @@
// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/
// collapse toggles. Used by clickElement's gridGroup/gridParent and
// gridTreeNode branches; the actual mouse click stays in the caller because
// it depends on the caller-local modifier-key handling.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { page } from '../core/state.mjs';
/**
* Locate the toggle icon for the grid row at `target.y`. Inspects the row
* under that Y-coordinate inside the resolved grid, returns the icon's
* center coordinates and current expanded state or `null` if no toggle
* icon is present (e.g. leaf node or detached row).
*
* @param {{y:number, gridId?:string}} target
* @param {number} formNum
* @param {object} opts
* @param {string} opts.iconSelector CSS selector inside .gridLine
* (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes)
* @param {string} opts.isExpandedExpr JS expression evaluated in browser
* context where `icon` is the matched element; must yield a boolean
* (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')")
* @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>}
*/
export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) {
return await page.evaluate(`(() => {
const p = ${JSON.stringify(`form${formNum}_`)};
const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
const body = grid?.querySelector('.gridBody');
if (!body) return null;
const targetY = ${target.y};
const lines = [...body.querySelectorAll('.gridLine')];
for (const line of lines) {
const lr = line.getBoundingClientRect();
if (targetY < lr.top || targetY > lr.bottom) continue;
const icon = line.querySelector(${JSON.stringify(iconSelector)});
if (icon) {
const r = icon.getBoundingClientRect();
const isExpanded = ${isExpandedExpr};
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
}
}
return null;
})()`);
}
/**
* Standard expand/toggle decision: should we click the toggle icon?
* - `toggle:true` always click.
* - `expand:true` click only if not already expanded.
* - `expand:false` click only if currently expanded.
* - If no icon found (`iconInfo` is null) click anyway (caller falls back to dblclick).
*
* @param {{isExpanded:boolean}|null} iconInfo
* @param {boolean|undefined} expand
* @param {boolean|undefined} toggle
* @returns {boolean}
*/
export function shouldClickToggle(iconInfo, expand, toggle) {
return toggle || !iconInfo
|| (expand === true && !iconInfo.isExpanded)
|| (expand === false && iconInfo.isExpanded);
}
@@ -0,0 +1,95 @@
// web-test table/grid v1.20 — Form-grid operations: read table rows, fill rows, delete rows.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM):
// табличные части документов, формы списков, ТЧ настроек и т.п.
// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs).
import { page, ensureConnected } from '../core/state.mjs';
import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs';
import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs';
import { isInputFocusedInGrid } from '../core/helpers.mjs';
import { dismissPendingErrors } from '../core/errors.mjs';
import { waitForStable } from '../core/wait.mjs';
import { clickElement } from '../core/click.mjs';
import { returnFormState } from '../core/helpers.mjs';
/** Read structured table data with pagination. Returns columns, rows, total count. */
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
ensureConnected();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('readTable: no form found');
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
}
/**
* Delete a row from the current table part.
* Single click to select the row, then Delete key to remove it.
*
* @param {number} row - 0-based row index to delete
* @param {Object} [options]
* @param {string} [options.tab] - Switch to this form tab before operating
* @returns {object} form state with { deleted, rowsBefore, rowsAfter }
*/
export async function deleteTableRow(row, { tab, table } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('deleteTableRow: no form found');
// Pre-resolve grid when table is specified
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
// 1. Switch tab if requested
if (tab) {
await clickElement(tab);
await page.waitForTimeout(500);
}
// 2. Find the target row and click to select it
const cellCoords = await page.evaluate(findDeleteRowCoordsScript(gridSelector, row));
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
const rowsBefore = cellCoords.total;
// Pre-click Escape: leftover edit-mode from a prior fillTableRow Tab-navigation.
// Without it the next mouse click may not select the row reliably (the active
// edit input intercepts the event timing).
if (await isInputFocusedInGrid({ gridSelector })) {
await page.keyboard.press('Escape');
await page.waitForTimeout(150);
}
// Single click to select the row
await page.mouse.click(cellCoords.x, cellCoords.y);
await page.waitForTimeout(300);
// Post-click Escape: clicking a Number/Date cell auto-enters edit mode in 1С.
// Delete in edit mode clears the cell buffer instead of deleting the row, so
// we exit edit first. The row remains selected after Escape — Delete acts on it.
if (await isInputFocusedInGrid({ gridSelector })) {
await page.keyboard.press('Escape');
await page.waitForTimeout(150);
}
// 3. Press Delete to remove the row
await page.keyboard.press('Delete');
await waitForStable();
// 4. Count rows after deletion
const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector));
return returnFormState({ deleted: row, rowsBefore, rowsAfter });
}
@@ -0,0 +1,957 @@
// web-test table/row-fill v1.23 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
} from '../core/state.mjs';
import {
detectFormScript, resolveGridScript, readTableScript,
countGridRowsScript, isTreeGridScript, findGridHeadCenterCoordsScript,
getSelectedOrLastRowIndexScript,
isNotInListCloudVisibleScript, clickShowAllInNotInListCloudScript,
sortFieldKeysByColindexScript, findCellCoordsByFieldsScript,
findNextCellCoordsByKeyScript, findCheckboxAtPointScript,
findRowCommitClickCoordsScript, getGridEditCheckScript,
readActiveGridCellScript, getElementCenterCoordsByIdScript,
} from '../../dom.mjs';
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs';
import { highlight, unhighlight } from '../recording/highlight.mjs';
import {
safeClick, findFieldInputId, returnFormState,
detectNewForm as helperDetectNewForm,
isInputFocused, isInputFocusedInGrid, findOpenPopup,
readEdd, isEddVisible, clickEddItemViaDispatch,
} from '../core/helpers.mjs';
import { clickElement } from '../core/click.mjs';
import { resolveRowIndexByFilter } from './click-cell.mjs';
import {
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
fillReferenceField, selectValue,
} from '../forms/select-value.mjs';
import { pasteText } from '../core/clipboard.mjs';
/**
* Fill a choice cell (_CB iCB, buttonKind==='choice') whose INPUT is already focused.
*
* Two kinds of cell carry the same choice button and are INDISTINGUISHABLE in the DOM
* (both `editInput`, readOnly:false):
* (a) editable value cell (Произвольный/примитив, РедактированиеТекста=Истина) typed text sticks;
* (b) pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь) typed text is rejected.
* The only reliable discriminator is behavioral: paste and watch the input value.
* stuck editable cell leave value in the INPUT (caller's Tab/commit persists it), method 'direct';
* rejected F4 form: isTypeDialog ? pickFromTypeDialog ('choice') : pickFromSelectionForm ('form').
*
* Does NOT navigate between cells caller owns Tab/dblclick/row-commit.
*
* @param {number} formNum base form number (for new-form detection)
* @param {string} text value to fill
* @param {Object} [opts]
* @param {string|null} [opts.type] explicit type for composite/value-list pick
* @param {string} [opts.fieldLabel] field name for diagnostics / selection-form search
* @returns {{ ok, method, error?, message?, value? }}
*/
async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } = {}) {
const norm = (s) => normYo((s || '').toLowerCase());
const before = await page.evaluate(`document.activeElement?.value || ''`);
// Re-fill guard: cell already holds the target (paste wouldn't change it → false "rejected").
if (before && norm(before).includes(norm(text))) {
return { ok: true, method: 'skip', value: before };
}
// Paste, then poll. Three outcomes, distinguished BEHAVIORALLY (not by value equality):
// (1) EDD autocomplete appears → reference/list cell → pick from the dropdown;
// (2) input changes to non-empty, no EDD → editable cell → leave value, method 'direct';
// (3) input unchanged (rejected) → НачалоВыбора pick-from-list → F4 selection form.
// A value-equality check on `after` is UNRELIABLE: numeric/date masks reformat the pasted
// text (grouping nbsp, decimal comma, padding) — e.g. "1234.56" → "1 234,56", "0,000"
// baseline. So we test "did the input change to non-empty" + "no autocomplete", never
// "does after contain text" (that false-negatives on reformatting → F4 → stray calculator).
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
let after = before, changed = false, eddSeen = false;
for (let i = 0; i < 6; i++) {
await page.waitForTimeout(100);
if (await isEddVisible()) { eddSeen = true; break; }
after = await page.evaluate(`document.activeElement?.value || ''`);
if (after !== before && after !== '') changed = true;
}
if (eddSeen) {
// Reference/list cell — pick a MATCHING item from the autocomplete. Only accept an
// exact (parenthetical-stripped) or substring match; never blind-pick items[0] — for a
// non-existent value 1C still lists unrelated entries, and picking the first silently
// writes the wrong reference. No match → fall through to the F4 selection form, which
// searches the full list and returns not_found if the value is truly absent.
const edd = await readEdd();
const items = (edd.items || []).map(i => i.name)
.filter(i => !/^Создать[\s:]/.test(i) && !/не найдено/i.test(i) && !/показать все/i.test(i));
const tgt = norm(text);
const pick = items.find(i => norm(i.replace(/\s*\([^)]*\)\s*$/, '')) === tgt)
|| items.find(i => norm(i).includes(tgt));
if (pick) {
await clickEddItemViaDispatch(pick);
await waitForStable();
return { ok: true, method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') };
}
// No matching item — dismiss the autocomplete and fall through to the F4 selection form.
await page.keyboard.press('Escape'); await page.waitForTimeout(200);
} else if (changed) {
// Editable cell — value lives in the INPUT; caller's Tab / end-of-row commit persists it.
return { ok: true, method: 'direct', value: after };
}
// Text rejected (pick-from-list cell) — nothing typed to clear (field is not text-editable).
// Dismiss any autocomplete hint, then open the choice form via F4.
if (await isEddVisible()) { await page.keyboard.press('Escape'); await page.waitForTimeout(200); }
await page.keyboard.press('F4');
let choiceForm = null;
for (let cw = 0; cw < 8; cw++) {
await page.waitForTimeout(200);
choiceForm = await helperDetectNewForm(formNum);
if (choiceForm !== null) break;
}
if (choiceForm === null) {
// F4 safety net: on an editable numeric/date cell mis-routed here, F4 opens a
// calculator/calendar (NOT a selection form). Close it — never leave the popup open
// (it blocks the UI) — and salvage: if the cell now holds a value, count it as 'direct'.
if (await findOpenPopup()) {
await page.keyboard.press('Escape');
for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); if (!(await findOpenPopup())) break; }
const nowVal = await page.evaluate(`document.activeElement?.value || ''`);
if (nowVal && nowVal !== before) return { ok: true, method: 'direct', value: nowVal };
}
return { ok: false, error: 'no_selection_form', message: `Cell "${fieldLabel || text}": F4 did not open a choice form` };
}
if (await isTypeDialog(choiceForm)) {
try {
await pickFromTypeDialog(choiceForm, type || text);
} catch (e) {
return { ok: false, error: 'not_found', message: e.message };
}
await waitForStable(formNum);
// A value form opened after the type pick → composite-value cell needs { value, type }.
const valForm = await helperDetectNewForm(formNum);
if (valForm !== null) {
await page.keyboard.press('Escape'); await page.waitForTimeout(300);
return { ok: false, error: 'type_required', message: `Cell "${fieldLabel || text}" expects { value, type }` };
}
return { ok: true, method: 'choice', value: text };
}
const pr = await pickFromSelectionForm(choiceForm, fieldLabel || text, text, formNum);
return pr.ok ? { ok: true, method: 'form' } : { ok: false, error: pr.error, message: pr.message };
}
/**
* Fill cells in the current table row via Tab navigation.
* Grid cells are only accessible sequentially (Tab) no random access.
*
* After "Добавить", 1C enters inline edit mode on the first cell.
* All inputs in the row are created hidden (offsetWidth=0); only the active one is visible.
* Tab moves through cells in a fixed order determined by the form configuration.
*
* @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" "ТоварыНоменклатура")
* @param {Object} [options]
* @param {string} [options.tab] - Switch to this form tab before operating
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
* @param {number|Object} [options.row] - Edit existing row: 0-based DOM-window index, or
* a `{ col: value }` filter (one or more columns, AND-matched) to locate the row by cell values
* @param {boolean|number} [options.scroll] - When `row` is a filter, scan beyond the current
* DOM window via PageDown (true = up to 50 presses, number = exact limit)
* @returns {{ filled[], notFilled[]?, form }}
*/
export async function fillTableRow(fields, { tab, add, row, table, scroll } = {}) {
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
if (formNum === null) throw new Error('fillTableRow: no form found');
// Pre-resolve grid when table is specified
let gridSelector;
if (table) {
const resolved = await page.evaluate(resolveGridScript(formNum, table));
if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
gridSelector = resolved.gridSelector;
}
try {
// 1. Switch tab if requested
if (tab) {
await clickElement(tab);
}
// 1b. Resolve a { col: value } row filter to a numeric DOM-window index (mirrors
// clickElement). After this, `row` is a number and all downstream code/recursion
// works unchanged. Filter targets an EXISTING row — incompatible with `add`.
if (row != null && typeof row === 'object') {
row = await resolveRowIndexByFilter({ formNum, gridSelector, filter: row, gridName: table, scroll });
}
// 2. Add new row if requested
let addedRowIdx = -1;
if (add) {
// Count rows before add — new row will be appended at this index
addedRowIdx = await page.evaluate(countGridRowsScript(gridSelector));
await clickElement('Добавить', { table });
// Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
for (let aw = 0; aw < 6; aw++) {
await page.waitForTimeout(150);
if (await isInputFocusedInGrid()) break;
}
}
// 2b. Enter edit mode on existing row by dblclick
if (row != null) {
// Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right
const sortedKeys = await page.evaluate(
sortFieldKeysByColindexScript(gridSelector, Object.keys(fields).map(k => k.toLowerCase())));
if (sortedKeys) {
// Rebuild fields in sorted order
const sortedFields = {};
for (const kl of sortedKeys) {
const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl);
if (origKey) sortedFields[origKey] = fields[origKey];
}
// Add any keys not matched in header (preserve original order for those)
for (const k of Object.keys(fields)) {
if (!(k in sortedFields)) sortedFields[k] = fields[k];
}
fields = sortedFields;
}
const cellCoords = await page.evaluate(
findCellCoordsByFieldsScript(gridSelector, row, Object.keys(fields).map(k => k.toLowerCase())));
if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
// Skip if cell already contains the desired value (single-field optimization)
const firstKey0 = Object.keys(fields)[0];
const rawFirstVal = fields[firstKey0];
const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === ''
? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal));
let firstFieldSkipped = false;
if (cellCoords.currentText && firstVal0 &&
cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) {
firstFieldSkipped = true;
if (Object.keys(fields).length === 1) {
return returnFormState({ filled: [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }] });
}
}
// Click first (tree grids enter edit on single click; dblclick toggles expand/collapse).
// Then escalate: dblclick → F4 if needed.
await page.mouse.click(cellCoords.x, cellCoords.y);
// Clear cell via Shift+F4 if value is empty
if (firstVal0 === '') {
await page.waitForTimeout(500);
// Check if click opened a selection form — close it first
let openedForm = await helperDetectNewForm(formNum);
if (openedForm !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
} else {
// No form opened — need to enter edit mode first (dblclick), then close any form that opens
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
await page.waitForTimeout(500);
openedForm = await helperDetectNewForm(formNum);
if (openedForm !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
}
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }];
// If more fields remain, process them on the same row
const remaining = { ...fields };
delete remaining[firstKey0];
if (Object.keys(remaining).length > 0) {
const more = await fillTableRow(remaining, { row, table });
results.push(...more.filled);
}
return returnFormState({ filled: results });
}
// Check if clicked cell is a checkbox (toggle-on-click, no edit mode)
const checkboxInfo = await page.evaluate(findCheckboxAtPointScript(cellCoords.x, cellCoords.y));
if (checkboxInfo !== null) {
// Checkbox cell found — click directly on the checkbox icon (not cell center)
const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim());
if (checkboxInfo.checked !== desired) {
await page.mouse.click(checkboxInfo.x, checkboxInfo.y);
await page.waitForTimeout(300);
}
const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }];
await waitForStable(formNum);
// If more fields remain, process them on the same row
const remaining = { ...fields };
delete remaining[firstKey0];
if (Object.keys(remaining).length > 0) {
const more = await fillTableRow(remaining, { row, table });
results.push(...more.filled);
}
return returnFormState({ filled: results });
}
let inEdit = false;
let directEditForm = null;
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
inEdit = await isInputFocused();
if (inEdit) break;
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
// Click didn't enter edit — try dblclick (works for flat grids)
if (!inEdit && directEditForm === null) {
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
inEdit = await isInputFocused();
if (inEdit) break;
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
}
// Still nothing — try F4 (opens selection for direct-edit cells)
if (!inEdit && directEditForm === null) {
await page.keyboard.press('F4');
for (let fw = 0; fw < 8; fw++) {
await page.waitForTimeout(200);
inEdit = await isInputFocused();
if (inEdit) break;
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
}
// When click entered INPUT mode but no selection form yet — try F4 only for tree grids
// (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop)
if (inEdit && directEditForm === null) {
const isTreeGrid = await page.evaluate(isTreeGridScript(gridSelector));
if (isTreeGrid) {
await page.keyboard.press('F4');
for (let fw = 0; fw < 8; fw++) {
await page.waitForTimeout(200);
directEditForm = await helperDetectNewForm(formNum);
if (directEditForm !== null) break;
}
// If F4 didn't open a selection form, fall through to Tab loop
}
}
// Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing).
// Handle each field by picking from selection form, then dblclick next cell.
if (directEditForm !== null) {
const pending = new Map();
for (const [key, val] of Object.entries(fields)) {
if (val && typeof val === 'object' && 'value' in val) {
pending.set(key, { value: String(val.value), type: val.type || null, filled: false });
} else {
pending.set(key, { value: String(val), type: null, filled: false });
}
}
const results = [];
// Helper: handle type dialog + pick from selection form
async function directEditPick(openedForm, key, info) {
let selForm = openedForm;
// Check if opened form is a type selection dialog (composite type field)
if (await isTypeDialog(selForm)) {
if (info.type) {
await pickFromTypeDialog(selForm, info.type);
await waitForStable(selForm);
// After type selection, detect the actual selection form
selForm = await helperDetectNewForm(formNum);
if (selForm === null) {
return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
}
} else {
// No type given — treat as a choice cell: the value IS the list item
// ("Выбрать тип"). Pick it; if a value form follows, it was genuinely a
// composite-value cell that needs {value, type}.
try {
await pickFromTypeDialog(selForm, info.value);
} catch (e) {
return { field: key, ok: false, error: 'not_found', message: e.message };
}
await waitForStable(formNum);
const after = await helperDetectNewForm(formNum);
if (after !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
return { field: key, ok: false, error: 'type_required', message: `Cell "${key}" expects { value, type }` };
}
return { field: key, ok: true, method: 'choice' };
}
}
const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, ok: false, error: pr.error, message: pr.message };
}
// First field: selection form is already open from the dblclick above
const firstKey = Object.keys(fields)[0];
const firstInfo = pending.get(firstKey);
if (firstFieldSkipped) {
firstInfo.filled = true;
results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText });
// Close the selection form that opened from the click
await page.keyboard.press('Escape');
await waitForStable(formNum);
} else {
const pickResult = await directEditPick(directEditForm, firstKey, firstInfo);
firstInfo.filled = true;
results.push(pickResult);
}
// Remaining fields: dblclick on each column cell individually
for (const [key, info] of pending) {
if (info.filled) continue;
// Find column for this key and dblclick on it
const nextCoords = await page.evaluate(findNextCellCoordsByKeyScript(gridSelector, row, key));
if (!nextCoords) {
info.filled = true;
results.push({ field: key, ok: false, error: 'column_not_found', message: `Column for "${key}" not found` });
continue;
}
// Skip if cell already contains the desired value
if (nextCoords.currentText && info.value &&
nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) {
info.filled = true;
results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText });
continue;
}
await page.mouse.dblclick(nextCoords.x, nextCoords.y);
await page.waitForTimeout(300);
// Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator
const inInputAfterDblclick = await isInputFocusedInGrid();
// Also check if a selection form already appeared
let selForm = await helperDetectNewForm(formNum);
if (selForm === null && inInputAfterDblclick) {
// Choice cell (bare _CB iCB) — editable value (text sticks) or pick-from-list
// (text rejected → F4 form). fillChoiceCell discriminates; row commit persists 'direct'.
const activeCell = await page.evaluate(readActiveGridCellScript());
if (activeCell.buttonKind === 'choice') {
const r = await fillChoiceCell(formNum, info.value, { type: info.type, fieldLabel: key });
info.filled = true;
results.push(r.ok
? { field: key, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
: { field: key, ok: false, error: r.error, message: r.message });
continue;
}
// Plain text/numeric field — fill via clipboard paste
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
// Dismiss EDD autocomplete if it appeared
if (await isEddVisible()) {
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
}
info.filled = true;
results.push({ field: key, ok: true, method: 'paste' });
continue;
}
// Poll for selection form (with F4 fallback if dblclick didn't open it)
if (selForm === null) {
for (let attempt = 0; attempt < 2 && selForm === null; attempt++) {
if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback
for (let sw = 0; sw < 6; sw++) {
await page.waitForTimeout(200);
selForm = await helperDetectNewForm(formNum);
if (selForm !== null) break;
}
}
}
if (selForm === null) {
info.filled = true;
results.push({ field: key, ok: false, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` });
continue;
}
const pr = await directEditPick(selForm, key, info);
info.filled = true;
results.push(pr);
}
// Commit the edit: click on a different row (Escape cancels in tree grids).
// Find the first visible row that is NOT the edited row and click it.
const commitCoords = await page.evaluate(findRowCommitClickCoordsScript(gridSelector, row));
if (commitCoords) {
await page.mouse.click(commitCoords.x, commitCoords.y);
} else {
await page.keyboard.press('Escape');
}
await waitForStable(formNum);
return returnFormState({ filled: results });
}
if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`);
} else {
// No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent)
const editCheck = await page.evaluate(getGridEditCheckScript());
if (!editCheck.inEdit) {
throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.');
}
}
// 4. Prepare pending fields for fuzzy matching
const pending = new Map();
for (const [key, val] of Object.entries(fields)) {
if (val === null || val === undefined || val === '') {
pending.set(key, { value: '', type: null, filled: false });
} else if (val && typeof val === 'object' && 'value' in val) {
const innerVal = val.value;
pending.set(key, {
value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal),
type: val.type || null, filled: false
});
} else {
pending.set(key, { value: String(val), type: null, filled: false });
}
}
const results = [];
const MAX_ITER = 40;
let prevCellId = null;
let nonInputCount = 0;
let firstCellId = null;
for (let iter = 0; iter < MAX_ITER; iter++) {
// Read focused element (INPUT or TEXTAREA inside grid = editable cell)
const cell = await page.evaluate(readActiveGridCellScript());
if (cell.tag !== 'INPUT' || !cell.fullName) {
// Not in an editable grid cell — Tab past (ERP has DIV focus between cells)
nonInputCount++;
// If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows
const onlyCheckboxLeft = [...pending.values()].every(p => p.filled ||
['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim()));
if (nonInputCount > 3 || onlyCheckboxLeft) break;
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
continue;
}
nonInputCount = 0;
// Track first cell to detect wrap-around (Tab looped back to row start)
if (firstCellId === null) firstCellId = cell.id;
else if (cell.id === firstCellId) break; // wrapped around — all cells visited
// Stuck detection: same cell twice in a row → force Tab
if (cell.id === prevCellId) {
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
prevCellId = null;
continue;
}
prevCellId = cell.id;
// Fuzzy match cell name to user field: exact → suffix → includes → no-space includes
const cellLower = cell.fullName.toLowerCase();
let matchedKey = null;
for (const [key, info] of pending) {
if (info.filled) continue;
const kl = key.toLowerCase();
if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) {
matchedKey = key;
break;
}
// CamelCase cell names have no spaces/dashes — try matching without spaces and dashes
const klNoSpace = kl.replace(/[\s\-]+/g, '');
if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) {
matchedKey = key;
break;
}
}
// Fallback: match by column header text (handles metadata typos in cell id)
if (!matchedKey && cell.headerText) {
const htLower = cell.headerText.toLowerCase();
for (const [key, info] of pending) {
if (info.filled) continue;
const kl = key.toLowerCase();
if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) {
matchedKey = key;
break;
}
}
}
if (!matchedKey) {
// Skip this cell
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
continue;
}
const info = pending.get(matchedKey);
const text = info.value;
// Clear cell if value is empty (Shift+F4 = native 1C clear)
if (text === '') {
await page.keyboard.press('Shift+F4');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' });
if ([...pending.values()].every(p => p.filled)) break;
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// If user specified a type, always clear and use type selection flow
if (info.type) {
await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type
await page.waitForTimeout(300);
await page.keyboard.press('F4');
// Poll for type dialog form to appear
let typeForm = null;
for (let tw = 0; tw < 6; tw++) {
await page.waitForTimeout(200);
typeForm = await helperDetectNewForm(formNum);
if (typeForm !== null) break;
}
if (typeForm !== null && await isTypeDialog(typeForm)) {
await pickFromTypeDialog(typeForm, info.type);
await waitForStable(typeForm);
// After type selection, check if a selection form opened (ref types)
const selForm = await helperDetectNewForm(formNum);
if (selForm === null) {
// Primitive type — poll for calculator/calendar popup or settle on INPUT
let hasPopup = null;
for (let pw = 0; pw < 5; pw++) {
await page.waitForTimeout(200);
hasPopup = await findOpenPopup();
if (hasPopup) break;
}
if (hasPopup) {
await page.keyboard.press('Escape');
// Poll for popup to disappear
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
if (!(await findOpenPopup())) break;
}
}
// Ensure we are in an editable INPUT for this cell
const inInput = await isInputFocused({ allowTextarea: true });
if (!inInput) {
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
if (cellRect) {
await page.mouse.dblclick(cellRect.x, cellRect.y);
// Poll for INPUT focus
for (let fw = 0; fw < 4; fw++) {
await page.waitForTimeout(150);
if (await isInputFocused({ allowTextarea: true })) break;
}
}
}
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
continue;
}
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
info.filled = true;
results.push(pickResult.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
: { field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
continue;
}
// F4 opened something but not a type dialog — close and report
if (typeForm !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'type_dialog_failed',
message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` });
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// Choice cell (_CB iCB): either an editable value cell (text sticks → direct input) or a
// pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь → text rejected → F4 form).
// fillChoiceCell discriminates behaviorally; both kinds are indistinguishable in the DOM.
if (cell.buttonKind === 'choice') {
const r = await fillChoiceCell(formNum, text, { type: info.type, fieldLabel: matchedKey });
info.filled = true;
results.push(r.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
: { field: matchedKey, cell: cell.fullName, ok: false, error: r.error, message: r.message });
// 'direct' leaves text in the INPUT — caller's Tab (or end-of-row commit on the last field) persists it.
if ([...pending.values()].every(p => p.filled)) break;
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
continue;
}
// === Fill this cell: clipboard paste (trusted event) ===
await page.keyboard.press('Control+A');
await pasteText(text);
await page.waitForTimeout(1500);
// Check if paste was rejected (composite-type cell blocks text input until type is selected)
const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`);
if (!inputAfterPaste && text) {
// No type specified — can't fill this composite-type cell
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'type_required',
message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` });
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// Check for EDD autocomplete (indicates reference field)
const edd = await readEdd();
const eddItems = edd.visible ? edd.items.map(i => i.name) : null;
if (eddItems && eddItems.length > 0) {
// Reference field with autocomplete — click best match
// Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...)
// but keep standalone enum values like "Создать" (no space/colon after)
const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i));
if (realItems.length > 0) {
const tgt = normYo(text.toLowerCase());
let pick = realItems.find(i =>
normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt);
if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt));
if (pick) {
// Click EDD item via dispatchEvent (bypasses div.surface overlay)
await clickEddItemViaDispatch(pick);
await waitForStable();
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true,
method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') });
} else {
// EDD listed items but NONE matches the requested value. Do NOT blind-pick the
// first item — when the typed text has no hit, 1C still shows unrelated entries
// (recent/full list), so items[0] would silently write the wrong reference.
// Dismiss, clear the typed text, report not_found.
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.waitForTimeout(200);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: `No match for "${text}" in autocomplete` });
}
} else {
// Only "Создать:" items — value not found in autocomplete
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: `No match for "${text}"` });
}
// Done? If so, don't Tab (avoids creating a new row after last cell)
if ([...pending.values()].every(p => p.filled)) break;
// Tab to move to next cell
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// No EDD — press Tab to commit the value
await page.keyboard.press('Tab');
await page.waitForTimeout(1000);
// Check for "нет в списке" cloud popup (reference field, value not found)
const notInList = await page.evaluate(isNotInListCloudVisibleScript());
if (notInList) {
// Cloud has "Показать все" link — try to open selection form via it
const clickedShowAll = await page.evaluate(clickShowAllInNotInListCloudScript());
if (clickedShowAll) {
await waitForStable(formNum);
// Check if selection form opened
const selForm = await helperDetectNewForm(formNum, { strict: true });
if (selForm !== null) {
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
info.filled = true;
if (pickResult.ok) {
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' });
continue;
}
// Not found in selection form — fall through to clear + skip
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
} else {
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: `Value "${text}" not in list` });
}
} else {
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: `Value "${text}" not in list` });
}
// 1C won't let us Tab away from an invalid ref value.
// Must clear the field first, then Tab to move on.
// Escape dismisses the cloud; Ctrl+A + Delete clears the text.
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
continue;
}
// Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs)
const newForm = await helperDetectNewForm(formNum);
if (newForm !== null) {
if (await isTypeDialog(newForm)) {
// Composite-type cell — need type to proceed
if (info.type) {
await pickFromTypeDialog(newForm, info.type);
await waitForStable(newForm);
// After type selection, the actual selection form should open
const selForm = await helperDetectNewForm(formNum);
if (selForm === null) {
// Primitive type — poll for calculator/calendar popup or settle on INPUT
let hasPopup = null;
for (let pw = 0; pw < 5; pw++) {
await page.waitForTimeout(200);
hasPopup = await findOpenPopup();
if (hasPopup) break;
}
if (hasPopup) {
await page.keyboard.press('Escape');
for (let dw = 0; dw < 4; dw++) {
await page.waitForTimeout(150);
if (!(await findOpenPopup())) break;
}
}
const inInput = await isInputFocused({ allowTextarea: true });
if (!inInput) {
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
if (cellRect) {
await page.mouse.dblclick(cellRect.x, cellRect.y);
for (let fw = 0; fw < 4; fw++) {
await page.waitForTimeout(150);
if (await isInputFocused({ allowTextarea: true })) break;
}
}
}
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
continue;
}
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
info.filled = true;
results.push(pickResult.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
: { field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
continue;
} else {
// No type specified — close dialog, clear cell, report error
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'type_required',
message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` });
continue;
}
}
// Not a type dialog — normal selection form
const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum);
info.filled = true;
results.push(pickResult.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
: { field: matchedKey, cell: cell.fullName, ok: false,
error: pickResult.error, message: pickResult.message });
continue;
}
// Plain field — value committed via Tab
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' });
// All done?
if ([...pending.values()].every(p => p.filled)) break;
// Tab already pressed — we're on next cell
}
// Commit the new row: click on the grid header to exit edit mode.
// Clicking a different data row would re-enter edit mode on that row.
// Without this commit click, the row stays in "uncommitted add" state
// and a subsequent Escape (e.g. from closeForm) would cancel the entire row.
const commitTarget = await page.evaluate(findGridHeadCenterCoordsScript(gridSelector));
if (commitTarget) {
await page.mouse.click(commitTarget.x, commitTarget.y);
await page.waitForTimeout(500);
} else {
// Fallback: Tab out of the last cell to commit the row
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
}
// Dismiss any leftover error modals
const err = await checkForErrors();
if (err?.modal) {
try {
const btn = await page.$('a.press.pressDefault');
if (btn) { await btn.click(); await page.waitForTimeout(500); }
} catch { /* OK */ }
}
const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key);
// Retry unfilled checkbox fields via direct click (Tab skips checkbox cells)
if (notFilled.length > 0) {
const checkboxFields = {};
for (const key of notFilled) {
const val = String(pending.get(key).value).toLowerCase().trim();
if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) {
checkboxFields[key] = pending.get(key).value;
}
}
if (Object.keys(checkboxFields).length > 0) {
// Use row index: addedRowIdx (from add mode) or fallback to selected row
const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(getSelectedOrLastRowIndexScript(gridSelector))
);
if (currentRow >= 0) {
const more = await fillTableRow(checkboxFields, { row: currentRow, table });
results.push(...more.filled);
for (const key of Object.keys(checkboxFields)) {
const idx = notFilled.indexOf(key);
if (idx >= 0) notFilled.splice(idx, 1);
}
}
}
}
const extras = { filled: results };
if (notFilled.length > 0) extras.notFilled = notFilled;
return returnFormState(extras);
} catch (e) {
if (e.message.startsWith('fillTableRow:')) throw e;
throw new Error(`fillTableRow: ${e.message}`);
}
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -138,6 +138,16 @@ jobs:
branch: port-windsurf-py
label: Windsurf
target_dir: .windsurf/skills
- platform: codeassistant
runtime: powershell
branch: port-codeassistant
label: Yandex Code Assistant
target_dir: .codeassistant/skills
- platform: codeassistant
runtime: python
branch: port-codeassistant-py
label: Yandex Code Assistant
target_dir: .codeassistant/skills
- platform: agents
runtime: powershell
branch: port-agents
+1
View File
@@ -104,6 +104,7 @@ python tools/cc-1c-skills/scripts/switch.py
| OpenCode | `.opencode/skills/` | [port-opencode](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-opencode) | [port-opencode-py](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-opencode-py) |
| Roo Code | `.roo/skills/` | [port-roo](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-roo) | [port-roo-py](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-roo-py) |
| Windsurf | `.windsurf/skills/` | [port-windsurf](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-windsurf) | [port-windsurf-py](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-windsurf-py) |
| Yandex Code Assistant | `.codeassistant/skills/` | [port-codeassistant](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-codeassistant) | [port-codeassistant-py](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-codeassistant-py) |
| Agent Skills | `.agents/skills/` | [port-agents](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-agents) | [port-agents-py](https://github.com/Nikolay-Shirokov/cc-1c-skills/tree/port-agents-py) |
Готовые ветки `port-*` пересобираются автоматически на каждое изменение в навыках. Если нужна свежая сборка прямо сейчас — соберите [локально через `switch.py`](#альтернативный-способ--собрать-локально-через-switchpy).
+20
View File
@@ -530,6 +530,26 @@ DataCompositionSchema
Стандартные варианты периодов (`v8:StandardPeriodVariant`): `Custom`, `Today`, `ThisWeek`, `ThisMonth`, `ThisQuarter`, `ThisYear`, `LastMonth`, `LastQuarter`, `LastYear` и др.
#### Значение-список (несколько значений по умолчанию)
Значением параметра может быть список — несколько элементов `<value>` подряд внутри
`<parameter>`, при `<valueListAllowed>true</valueListAllowed>`:
```xml
<parameter>
<name>ВидыСубконто</name>
<valueType>
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные</v8:Type>
</valueType>
<value xsi:type="dcscor:DesignTimeValue">ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты</value>
<value xsi:type="dcscor:DesignTimeValue">ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры</value>
<useRestriction>true</useRestriction>
<valueListAllowed>true</valueListAllowed>
</parameter>
```
Порядок элементов: `name, title, valueType, value*, useRestriction, …, valueListAllowed`.
---
## 9. Макеты областей (template)
+828 -41
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -339,6 +339,12 @@ XML-маппинг — по `<group>` на каждый элемент:
**Парсинг:** `"A: T = V"``name=A`, `type=T`, `value=V`. Значение `LastMonth` и другие варианты периодов → `v8:StandardPeriod` с `v8:variant`.
`<default>` может быть **списком** — несколько значений через запятую (с `'...'` для запятой внутри значения). В этом случае эмитятся несколько `<value>`, а `valueListAllowed=true` выводится автоматически (явный `@valueList` не нужен). Эквивалент объектной формы `"value": [ ... ]`.
```json
"parameters": ["Виды: ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные = ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"]
```
### @autoDates
Флаг `@autoDates` в shorthand параметра автоматически генерирует два дополнительных параметра:

Some files were not shown because too many files have changed in this diff Show More