From ad8a2e5cabcbfebc79771b34e5da0c32bf47c6fa Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Thu, 16 Jul 2026 20:59:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20readTable=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D1=82=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B2=D0=B5=D0=B4=D1=83=D1=89=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readTable матчил только спрайты pictureCollection (именованные pic-колонки), а ведущая иконка состояния приходит из convertPicture?url=e1csys//.zip&gx=N и молча дропалась. Проверить «помечен на удаление» можно было только выводом колонки через «Настроить список» в каждом тесте. Строка списка объектов теперь отдаёт плоские булевы _deleted / _posted / _predefined / _completed / _started / _finished и сырьё _rowPic для диагностики. Ключ словаря — ПОЛНЫЙ путь спрайта, не имя файла: basic/folder.zip и accnt/folder.zip — разные файлы с одинаковым именем и разной раскладкой gx (в basic gx=1 элемент, в accnt gx=1 предопределённый). Отсутствие булева значит «не знаю», а не false: ось неприменима либо кадр не расшифрован. Дефолт false отвергнут — врал бы молча в зелёную сторону. Пути и раскладки сняты живьём на ERP; у Task/BusinessProcess раскладка сверена с данными списка. Кадры 4/5 basic/folder.zip (второе измерение — иерархия элементов) расшифрованы по байтовому равенству кадров: gx4 ≡ gx1, gx5 ≡ gx3. Стенд: документы в заданных состояниях + помеченный элемент справочника + безымянная picture-колонка ПЕРЕД значком состояния — воспроизводит ловушку «первый .gridBoxImg не тот» (проверено подменой на наивный экстрактор). Тест 22-row-state; полный регресс 27/27. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/web-test/SKILL.md | 16 +++ .claude/skills/web-test/scripts/dom.mjs | 2 +- .claude/skills/web-test/scripts/dom/grid.mjs | 18 ++- .../skills/web-test/scripts/dom/row-state.mjs | 116 ++++++++++++++++++ docs/web-test-guide.md | 17 +++ .../integration/build-webtest-config.test.mjs | 50 ++++++++ tests/web-test/22-row-state.test.mjs | 96 +++++++++++++++ 7 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/web-test/scripts/dom/row-state.mjs create mode 100644 tests/web-test/22-row-state.test.mjs diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 22ae1c22..42fd6266 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -191,6 +191,22 @@ Special row fields: - `hierarchical: true` — list has groups (on result object) - `viewMode: 'tree'` — tree view active (on result object) +Row state — in object lists, decoded from the row's state icon (no need to add a column to the list): +- `_deleted: true|false` — marked for deletion (catalogs, documents, tasks, business processes, charts of accounts/calculation types) +- `_posted: true|false` — documents +- `_predefined: true|false` — catalogs, charts of accounts/calculation types +- `_completed: true|false` — tasks +- `_started`, `_finished` — business processes +- `_rowPic: ':'` — raw icon id, for diagnostics + +```js +const t = await readTable(); +const doc = t.rows.find(r => r['Номер'] === 'ТД00-000005'); +if (doc._deleted === true) { /* marked for deletion */ } +``` + +**A missing state field means "unknown", never `false`** — the property may not apply (documents have no `_predefined`), or the icon may be unrecognised. So `if (!row._deleted)` is unsafe: it reads "unknown" as "not deleted". Compare explicitly (`=== true` / `=== false`) and treat `undefined` as a third outcome. Rows outside object lists (form tabular sections, value lists) have no state fields at all. If `_rowPic` is present but the booleans aren't, report its value — that icon needs decoding support. + **`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 diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index e145ba00..ab5396ef 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -1,4 +1,4 @@ -// web-test dom v1.18 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.19 — facade re-exporting injectable DOM scripts from dom/ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Facade: re-exports DOM selector & semantic mapping script generators. diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 8903a508..36eea11e 100644 --- a/.claude/skills/web-test/scripts/dom/grid.mjs +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -1,6 +1,7 @@ -// web-test dom/grid v1.12 — grid resolution + table reading + edit-time helpers +// web-test dom/grid v1.13 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.mjs'; +import { ROW_STATE_FN } from './row-state.mjs'; /** * Resolve a specific grid by semantic name (table parameter). @@ -95,6 +96,16 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto return { gx: m ? m[1] : '0' }; } ${HEADERLESS_GRID_FN} + ${ROW_STATE_FN} + + // Write the leading row-state sprite onto a row: _rowPic (raw, always) + decoded booleans + // (only when the sprite path AND frame are known — absence means "unknown", never false). + function applyRowState(row, line) { + const st = rowStateInfo(line); + if (!st) return; + row._rowPic = st.rowPic; + if (st.axes) Object.keys(st.axes).forEach(k => { row['_' + k] = st.axes[k]; }); + } // DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells const head = grid.querySelector('.gridHead'); @@ -139,12 +150,13 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto } row[c.name] = val; }); - // Row meta — mirrors the headed path (group/parent/tree/level/selected) + // Row meta — mirrors the headed path (group/parent/tree/level/selected/state) const imgBox = line.querySelector('.gridBoxImg'); if (imgBox) { if (imgBox.querySelector('.gridListH')) row._kind = 'group'; else if (imgBox.querySelector('.gridListV')) row._kind = 'parent'; } + applyRowState(row, line); const treeBox = line.querySelector('.gridBoxTree'); if (treeBox) { const treeIcon = imgBox?.querySelector('[tree="true"]'); @@ -331,6 +343,8 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto if (imgBox.querySelector('.gridListH')) row._kind = 'group'; else if (imgBox.querySelector('.gridListV')) row._kind = 'parent'; } + // Leading row-state sprite → _rowPic + decoded booleans + applyRowState(row, line); // Tree mode: detect expand/collapse state and indent level const treeBox = line.querySelector('.gridBoxTree'); if (treeBox) { diff --git a/.claude/skills/web-test/scripts/dom/row-state.mjs b/.claude/skills/web-test/scripts/dom/row-state.mjs new file mode 100644 index 00000000..ebb6b095 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/row-state.mjs @@ -0,0 +1,116 @@ +// web-test dom/row-state v1.0 — leading row-state sprite decoding +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +/** + * Ведущая иконка состояния строки списка. + * + * 1С рисует состояние строки (проведён / помечен на удаление / выполнена / …) отдельным спрайтом, + * приходящим из `e1cib/convertPicture?url=e1csys//.zip&…&gx=`, где `gx` — индекс кадра. + * Это НЕ то же самое, что именованные pic-колонки (`pictureCollection/picture/`) — у тех другой + * URL и другой смысл (значение ячейки), их разбирает picInfo() в grid.mjs. + * + * Ключ словаря — ПОЛНЫЙ путь спрайта, не имя файла: `basic/folder.zip` (справочники) и + * `accnt/folder.zip` (план счетов) — разные файлы с одинаковым именем и РАЗНОЙ раскладкой gx + * (у basic gx=1 — элемент, у accnt gx=1 — предопределённый). Нормализация до имени файла склеила бы + * их и дала неверную расшифровку. + * + * Набор осей свой у каждого спрайта: docList → deleted+posted, folder → deleted+predefined, + * Task → deleted+completed. Неприменимая ось не эмитится. Незнакомый путь или нерасшифрованный gx → + * только `_rowPic`, без булевых: ОТСУТСТВИЕ БУЛЕВА ЗНАЧИТ «НЕ ЗНАЮ», а не false. + * + * Раскладки сняты живьём на ERP и подтверждены отрисовкой кадров спрайта (convertPicture&scale). + */ +export const ROW_STATE_FN = `function rowStateInfo(line) { + // Словарь: полный путь → (gx → набор осей). null = кадр не расшифрован (только _rowPic). + const SPRITES = { + // Документы, журналы документов. Спрайт — СЕТКА: база + 3·пометка_удаления. + // База: 0 записан, 1 проведён, 2 «загнутый угол» (не расшифрован). + // Кадров ровно 6 (gx 6..8 пустые). gx4 (проведён+помечен) на практике недостижим: + // пометка проведённого документа его распроводит. + 'e1csys/basic/docList.zip': function (gx) { + if (gx > 5) return null; + const base = gx % 3, deleted = gx >= 3; + if (base === 2) return { deleted: deleted }; // база не расшифрована → posted не эмитим + return { deleted: deleted, posted: base === 1 }; + }, + // Справочники, планы видов характеристик. Ось группа/элемент СОЗНАТЕЛЬНО не выводим — + // _kind живёт от .gridListH/.gridListV, на нём завязана иерархия. + // + // Кадры 4/5 — не «неизвестное состояние», а ВТОРОЕ измерение: у справочника с иерархией + // элементов (ERP «Партнеры») родитель — элемент, а не папка, и платформа берёт кадр 4. + // Кадры попарно байт-в-байт равны: gx4 ≡ gx1 (чистый элемент), gx5 ≡ gx3 (элемент + красный + // крест), gx8 ≡ gx7. Что означает второе измерение — знать не нужно: обе НАШИ оси кадр + // задаёт однозначно (крест есть → помечен). + 'e1csys/basic/folder.zip': { + 0: { deleted: false, predefined: false }, // группа + 1: { deleted: false, predefined: false }, // элемент + 2: { deleted: true, predefined: false }, // группа, помечена + 3: { deleted: true, predefined: false }, // элемент, помечен + 4: { deleted: false, predefined: false }, // элемент-узел иерархии (кадр ≡ gx1) + 5: { deleted: true, predefined: false }, // элемент-узел, помечен (кадр ≡ gx3) + 6: { deleted: false, predefined: true }, // группа, предопределённая + 7: { deleted: false, predefined: true }, // элемент, предопределённый + 8: { deleted: false, predefined: true }, // кадр ≡ gx7 + }, + // План счетов — ОТДЕЛЬНЫЙ спрайт с той же basename, но иной раскладкой. + 'e1csys/accnt/folder.zip': { + 0: { deleted: false, predefined: false }, + 1: { deleted: false, predefined: true }, + 2: { deleted: true, predefined: false }, + }, + // Задачи. Бит-поле: 1·Выполнена + 2·ПометкаУдаления. Сверено с данными на ERP. + 'e1csys/bp/Task.zip': function (gx) { + if (gx > 3) return null; + return { completed: (gx & 1) === 1, deleted: (gx & 2) === 2 }; + }, + // Бизнес-процессы. Бит-поле: 4·Стартован + 2·Завершён + 1·ПометкаУдаления. Сверено с данными. + 'e1csys/bp/BusinessProcess.zip': function (gx) { + if (gx > 7) return null; + return { started: (gx & 4) === 4, finished: (gx & 2) === 2, deleted: (gx & 1) === 1 }; + }, + // Планы видов расчёта. + 'e1csys/calc/calcKindImg.zip': { + 0: { deleted: false, predefined: false }, + 1: { deleted: true, predefined: false }, + 2: { deleted: false, predefined: true }, + }, + // Планы обмена. gx 1, 4..7 не сняты → только _rowPic. + 'e1csys/backend/DataExchangeImages.zip': { + 0: { thisNode: false }, + 2: { thisNode: true }, + }, + }; + + function decodeUrl(s) { + // В DOM путь закодирован ДВАЖДЫ: url=e1csys%252Fbasic%252FdocList.zip + let v = s; + for (let i = 0; i < 3; i++) { + let d; + try { d = decodeURIComponent(v); } catch (e) { break; } + if (d === v) break; + v = d; + } + return v; + } + + // Ведущих значков в строке может быть несколько в РАЗНЫХ боксах: вид операции + // (pictureCollection, произвольная картинка конфигурации), tree-toggle и состояние. + // Поэтому перебираем все .dIB строки, а не берём первый .gridBoxImg. + const dibs = line.querySelectorAll('.gridBoxImg .dIB'); + for (let i = 0; i < dibs.length; i++) { + const d = dibs[i]; + if (d.getAttribute('tree') === 'true') continue; + const bg = d.style.backgroundImage || ''; + if (!bg.includes('convertPicture')) continue; + const um = bg.match(/[?&]url=([^&"')]+)/); + if (!um) continue; + const path = decodeUrl(um[1]); + if (!/\\.zip$/.test(path)) continue; + const gm = bg.match(/[?&]gx=(\\d+)/); + const gx = gm ? parseInt(gm[1], 10) : 0; + + const entry = SPRITES[path]; + const axes = typeof entry === 'function' ? entry(gx) : (entry ? entry[gx] : null); + return { rowPic: path + ':' + gx, axes: axes || null }; + } + return null; +}`; diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md index 7fa6261b..41501e55 100644 --- a/docs/web-test-guide.md +++ b/docs/web-test-guide.md @@ -260,6 +260,23 @@ console.log('Расшифровка:', JSON.stringify(drilldown.rows)); - `_selected: true` — строка выделена (подсвечена). Используйте с `clickElement({ modifier: 'ctrl'|'shift' })` для проверки мультиселекции - На объекте результата: `hierarchical: true`, `viewMode: 'tree'` +Состояние строки — в списках объектов, расшифровывается по иконке состояния (выводить колонку в список не нужно): + +- `_deleted: true|false` — помечен на удаление (справочники, документы, задачи, бизнес-процессы, планы счетов и видов расчёта) +- `_posted: true|false` — документы +- `_predefined: true|false` — справочники, планы счетов и видов расчёта +- `_completed: true|false` — задачи +- `_started`, `_finished` — бизнес-процессы +- `_rowPic: '<иконка>:'` — сырой идентификатор иконки, для диагностики + +```js +const t = await readTable(); +const doc = t.rows.find(r => r['Номер'] === 'ТД00-000005'); +if (doc._deleted === true) { /* помечен на удаление */ } +``` + +**Отсутствие поля состояния значит «не знаю», а не `false`** — свойство может быть неприменимо (у документов нет `_predefined`) либо иконка не распознана. Поэтому `if (!row._deleted)` небезопасно: «не знаю» читается как «не помечен». Сравнивайте явно (`=== true` / `=== false`), а `undefined` считайте третьим исходом. У строк вне списков объектов (табличные части форм, списки значений) полей состояния нет вовсе. Если `_rowPic` есть, а булевых нет — пришлите его значение, для этой иконки нужна поддержка расшифровки. + **Виртуализация и `hasMore`.** 1С виртуализирует и динамические списки, и табличные части — в DOM лежит только окно видимых строк. Поля `total` / `shown` — это размер DOM-окна, а **не** размер коллекции. Чтобы понять, есть ли строки за пределами окна, используйте `hasMore`: ```js diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index bce2f3bb..0a3d6233 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -349,6 +349,17 @@ export const steps = [ \t\tЭлемент.ВидНоменклатуры = Перечисления.ВидыНоменклатуры.Товар; \t\tЭлемент.Записать(); \tКонецЦикла; +\t// Элемент С ПОМЕТКОЙ УДАЛЕНИЯ — для теста ведущей иконки состояния строки (22-row-state). +\t// Внутри группы Товары, а не на верхнем уровне: 08-hierarchy проверяет ровно 3 группы сверху. +\tПомеченный = Справочники.Номенклатура.СоздатьЭлемент(); +\tПомеченный.Родитель = ГруппаТовары; +\tПомеченный.Наименование = "Товар помеченный"; +\tПомеченный.Артикул = "TDEL1"; +\tПомеченный.Цена = 999; +\tПомеченный.Активен = Истина; +\tПомеченный.ВидНоменклатуры = Перечисления.ВидыНоменклатуры.Товар; +\tПомеченный.Записать(); +\tПомеченный.Ссылка.ПолучитьОбъект().УстановитьПометкуУдаления(Истина); \tДля Сч = 1 По 10 Цикл \t\tЭлемент = Справочники.Номенклатура.СоздатьЭлемент(); \t\tЭлемент.Родитель = ГруппаУслуги; @@ -427,6 +438,37 @@ export const steps = [ \t\tСтрока.Сумма = Строка.Количество * Строка.Цена; \tКонецЦикла; \tДокДлинный.Записать(РежимЗаписиДокумента.Запись); +\tЗаполнитьДокументыСостояний(Организация, Контрагенты, Номенклатура); +КонецПроцедуры + +// Документы в ЗАДАННЫХ состояниях — для теста ведущей иконки состояния строки (22-row-state). +// Ищутся по Комментарий, не по позиции: список сортируется по дате, порядок не гарантирован. +Процедура ЗаполнитьДокументыСостояний(Организация, Контрагенты, Номенклатура) +\tСостояния = Новый Массив; +\tСостояния.Добавить("StatePosted"); +\tСостояния.Добавить("StateDeleted"); +\tСостояния.Добавить("StateWritten"); +\tДля Каждого Состояние Из Состояния Цикл +\t\tДок = Документы.ПриходнаяНакладная.СоздатьДокумент(); +\t\tДок.Дата = ТекущаяДата(); +\t\tДок.Организация = Организация; +\t\tДок.Контрагент = Контрагенты[0]; +\t\tДок.Склад = "Основной"; +\t\tДок.Комментарий = Состояние; +\t\tСтрока = Док.Товары.Добавить(); +\t\tСтрока.Номенклатура = Номенклатура[0]; +\t\tСтрока.Количество = 1; +\t\tСтрока.Цена = 100; +\t\tСтрока.Сумма = 100; +\t\tЕсли Состояние = "StatePosted" Тогда +\t\t\tДок.Записать(РежимЗаписиДокумента.Проведение); +\t\tИначе +\t\t\tДок.Записать(РежимЗаписиДокумента.Запись); +\t\t\tЕсли Состояние = "StateDeleted" Тогда +\t\t\t\tДок.Ссылка.ПолучитьОбъект().УстановитьПометкуУдаления(Истина); +\t\t\tКонецЕсли; +\t\tКонецЕсли; +\tКонецЦикла; КонецПроцедуры `, }, @@ -848,6 +890,14 @@ export const steps = [ ], elements: [ { table: 'Список', path: 'Список', columns: [ + // Произвольная картинка в ВЕДУЩЕЙ позиции — рядом с платформенным значком состояния + // строки. Даёт строку с двумя значками: pictureCollection (эта колонка) и + // convertPicture (состояние). Тест 22-row-state ловит на ней экстрактор, который + // берёт первый .gridBoxImg вместо нужного. + // titleLocation none — колонка БЕЗ шапки: попадает в ветку именования безымянных + // колонок readTable (по тултипу заголовка → '(picture)'), а не в обычную. + { picField: 'Метка', path: 'Список.Posted', title: 'Метка', titleLocation: 'none', + valuesPicture: 'StdPicture.Favorites' }, { input: 'Date', path: 'Список.Date', title: 'Дата' }, { input: 'Number', path: 'Список.Number', title: 'Номер' }, { input: 'Контрагент', path: 'Список.Контрагент', title: 'Контрагент' }, diff --git a/tests/web-test/22-row-state.test.mjs b/tests/web-test/22-row-state.test.mjs new file mode 100644 index 00000000..26c9b09d --- /dev/null +++ b/tests/web-test/22-row-state.test.mjs @@ -0,0 +1,96 @@ +export const name = 'row-state: ведущая иконка состояния строки (_rowPic + булевы)'; +export const tags = ['row-state', 'table']; +export const timeout = 90000; + +export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, getPage, assert, step, log }) { + + await step('документы: _posted / _deleted по заданным состояниям', async () => { + await navigateSection('Склад'); + await openCommand('Приходная накладная'); + const t = await readTable({ maxRows: 50 }); + const byComment = (c) => t.rows.find(r => r['Комментарий'] === c); + + const posted = byComment('StatePosted'); + const deleted = byComment('StateDeleted'); + const written = byComment('StateWritten'); + log(`posted=${JSON.stringify(posted?._rowPic)} deleted=${JSON.stringify(deleted?._rowPic)} written=${JSON.stringify(written?._rowPic)}`); + assert.ok(posted && deleted && written, 'три документа-фикстуры состояний видны в списке'); + + // Проведён: _posted true, пометки нет + assert.equal(posted._posted, true, 'StatePosted → _posted true'); + assert.equal(posted._deleted, false, 'StatePosted → _deleted false'); + + // Помечен на удаление. Пометка проведённого его распроводит, поэтому _posted здесь false. + assert.equal(deleted._deleted, true, 'StateDeleted → _deleted true'); + + // Записан, но не проведён — обе оси явный false, а не отсутствие ключа + assert.equal(written._posted, false, 'StateWritten → _posted false'); + assert.equal(written._deleted, false, 'StateWritten → _deleted false'); + + // Сырьё указывает на платформенный спрайт документов + assert.ok(/^e1csys\/basic\/docList\.zip:\d+$/.test(posted._rowPic), `_rowPic формата docList.zip: (got ${posted._rowPic})`); + }); + + await step('ловушка: произвольная картинка ПЕРЕД значком состояния не подменяет его', async () => { + // Безымянная колонка «Метка» (picField над Posted) рисует pictureCollection в colindex 0, + // а платформенный значок состояния идёт ПОСЛЕ неё. Экстрактор, берущий + // line.querySelector('.gridBoxImg') вместо перебора всех .dIB, схватит картинку колонки + // и потеряет состояние. Гард ниже — через DOM: колонка безымянная и в columns не + // попадает (её наличие readTable определяет по первой строке, а там Posted=false → + // картинки нет), поэтому проверить её через публичный API нельзя. + const page = await getPage(); + const icons = await page.evaluate(() => { + const grid = [...document.querySelectorAll('.grid')].find(g => g.offsetWidth > 0 && g.offsetHeight > 0); + const line = [...grid.querySelectorAll('.gridBody .gridLine')].find(l => (l.innerText || '').includes('StatePosted')); + if (!line) return null; + return [...line.querySelectorAll('.gridBoxImg .dIB')] + .map(d => d.style.backgroundImage || '') + .filter(Boolean) + .map(bg => bg.includes('convertPicture') ? 'state' : (bg.includes('pictureCollection') ? 'arbitrary' : 'other')); + }); + log(`icons in row: ${JSON.stringify(icons)}`); + assert.ok(icons, 'строка StatePosted найдена в DOM'); + assert.includes(icons, 'arbitrary', 'ловушка на месте: в строке есть произвольная картинка'); + assert.includes(icons, 'state', 'в строке есть платформенный значок состояния'); + assert.equal(icons[0], 'arbitrary', 'произвольная картинка идёт ПЕРВОЙ — первый .gridBoxImg не тот'); + + const t = await readTable({ maxRows: 50 }); + const posted = t.rows.find(r => r['Комментарий'] === 'StatePosted'); + assert.ok(posted, 'документ StatePosted виден'); + assert.ok(posted._rowPic?.startsWith('e1csys/basic/docList.zip:'), + `_rowPic должен указывать на спрайт состояния, а не на картинку колонки (got ${posted._rowPic})`); + assert.equal(posted._posted, true, 'состояние разобрано несмотря на картинку перед ним'); + }); + + await step('справочник: _deleted / _predefined на элементах Номенклатуры', async () => { + await navigateSection('Склад'); + await openCommand('Номенклатура'); + await clickElement('Ещё'); + await clickElement('Установить стандартные настройки'); + // Список показывает группы верхнего уровня — элементы прячутся внутри Товары. + await clickElement('Товары', { expand: true }); + const t = await readTable({ maxRows: 50 }); + + const marked = t.rows.find(r => r['Наименование'] === 'Товар помеченный'); + const normal = t.rows.find(r => r['Наименование'] === 'Товар 01'); + log(`marked=${JSON.stringify(marked?._rowPic)} normal=${JSON.stringify(normal?._rowPic)}`); + assert.ok(marked && normal, 'помеченный и обычный товары видны'); + + assert.equal(marked._deleted, true, 'Товар помеченный → _deleted true'); + assert.equal(normal._deleted, false, 'Товар 01 → _deleted false'); + assert.equal(normal._predefined, false, 'Товар 01 → _predefined false'); + assert.ok(/^e1csys\/basic\/folder\.zip:\d+$/.test(normal._rowPic), `_rowPic формата folder.zip: (got ${normal._rowPic})`); + }); + + await step('негатив: у табчасти формы ведущей иконки нет — полей не появляется', async () => { + await navigateSection('Склад'); + await openCommand('Приходная накладная'); + await clickElement('Создать'); + const t = await readTable({ table: 'Товары' }); + // Табчасть — не список объектов, состояния у строки нет. Отсутствие ключа значит + // «не знаю», и это правильный ответ, а не _deleted:false. + assert.ok(t.rows.every(r => r._rowPic === undefined), 'ни у одной строки нет _rowPic'); + assert.ok(t.rows.every(r => r._deleted === undefined), 'ни у одной строки нет _deleted'); + await closeForm(); + }); +}