From 7c9769c64454d08e1f3678777640cb9fbc9605b4 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 31 May 2026 17:26:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20fillTableRow=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82=20=D1=8F=D1=87=D0=B5?= =?UTF-8?q?=D0=B9=D0=BA=D1=83-=D0=B2=D1=8B=D0=B1=D0=BE=D1=80-=D0=B8=D0=B7-?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поле с кнопкой выбора и обработчиком НачалоВыбора (значение выбирается из программного списка — например колонка Тип в типовой Консоли запросов) раньше заполнялось 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 --- .../skills/web-test/scripts/dom/grid-edit.mjs | 14 ++- .../scripts/engine/forms/select-value.mjs | 24 ++++- .../scripts/engine/table/row-fill.mjs | 96 ++++++++++++++++++- .../integration/build-webtest-config.test.mjs | 30 ++++++ tests/web-test/16-tree-form.test.mjs | 26 ++++- 5 files changed, 178 insertions(+), 12 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom/grid-edit.mjs b/.claude/skills/web-test/scripts/dom/grid-edit.mjs index c1bfe1a8..3e480445 100644 --- a/.claude/skills/web-test/scripts/dom/grid-edit.mjs +++ b/.claude/skills/web-test/scripts/dom/grid-edit.mjs @@ -255,10 +255,22 @@ export function readActiveGridCellScript() { } } } + // 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 + headerText, buttonKind }; } } diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index 953b25cf..afd2dab2 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -1,4 +1,4 @@ -// web-test forms/select-value v1.20 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// web-test forms/select-value v1.21 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -265,6 +265,20 @@ export async function pickFromTypeDialog(formNum, typeName) { 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); + } + } + // Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists) const scan = await readVisibleRows(); @@ -278,7 +292,7 @@ export async function pickFromTypeDialog(formNum, typeName) { } if (scan.matches.length > 1) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + 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`); } @@ -307,7 +321,7 @@ export async function pickFromTypeDialog(formNum, typeName) { const findFormNum = await page.evaluate(findChildFormByButtonScript(formNum, 'Find')); if (findFormNum === null) { - await page.keyboard.press('Escape'); + await dismissTypeDialog(); await waitForStable(); throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection'); } @@ -320,14 +334,14 @@ export async function pickFromTypeDialog(formNum, typeName) { const afterSearch = await readVisibleRows(); if (afterSearch.matches.length === 0) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await dismissTypeDialog(); await waitForStable(); throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` + `. Visible: ${(scan.visible || []).join(', ')}`); } if (afterSearch.matches.length > 1) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await dismissTypeDialog(); await waitForStable(); throw new Error(`selectValue: multiple types match "${typeName}": ${afterSearch.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); } diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index 3ce7ed78..80561650 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -1,4 +1,4 @@ -// web-test table/row-fill v1.19 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// web-test table/row-fill v1.20 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -245,10 +245,22 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; } } else { - // No type specified — close type dialog and report error - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - return { field: key, ok: false, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; + // 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); @@ -294,6 +306,24 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // Also check if a selection form already appeared let selForm = await helperDetectNewForm(formNum); if (selForm === null && inInputAfterDblclick) { + // Choice cell (bare _CB list-pick) — paste would revert silently; open via F4. + const activeCell = await page.evaluate(readActiveGridCellScript()); + if (activeCell.buttonKind === 'choice') { + await page.keyboard.press('F4'); + let cForm = null; + for (let cw = 0; cw < 8; cw++) { + await page.waitForTimeout(200); + cForm = await helperDetectNewForm(formNum); + if (cForm !== null) break; + } + if (cForm !== null) { + const pr = await directEditPick(cForm, key, info); + info.filled = true; + results.push(pr); + continue; + } + // F4 opened nothing — fall through to paste (best effort) + } // Plain text/numeric field — fill via clipboard paste await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); await page.waitForTimeout(400); @@ -529,6 +559,62 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { continue; } + // Choice cell: value is picked from a programmatic list (field with НачалоВыбора → + // ПоказатьВыборЭлемента, e.g. a "Выбрать тип" list). Plain paste reverts silently, + // so open the choice form via F4 and pick from it. + if (cell.buttonKind === 'choice') { + 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) { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: false, + error: 'no_selection_form', message: `Cell "${matchedKey}": F4 did not open a choice form` }); + await page.keyboard.press('Tab'); await page.waitForTimeout(500); + continue; + } + if (await isTypeDialog(choiceForm)) { + try { + await pickFromTypeDialog(choiceForm, text); + } catch (e) { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: false, + error: 'not_found', message: e.message }); + await page.keyboard.press('Tab'); await page.waitForTimeout(500); + continue; + } + await waitForStable(formNum); + // If a value form opened after the pick, this was a composite-value cell → needs {value, type} + const valForm = await helperDetectNewForm(formNum); + if (valForm !== null) { + await page.keyboard.press('Escape'); await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: false, + error: 'type_required', message: `Cell "${matchedKey}" expects { value, type }` }); + await page.keyboard.press('Tab'); await page.waitForTimeout(500); + continue; + } + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'choice', value: text }); + if ([...pending.values()].every(p => p.filled)) break; + await page.keyboard.press('Tab'); await page.waitForTimeout(500); + continue; + } + // F4 opened a regular selection form (reference via CB) — pick from it + const pr = await pickFromSelectionForm(choiceForm, matchedKey, text, formNum); + info.filled = true; + results.push(pr.ok + ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' } + : { field: matchedKey, cell: cell.fullName, ok: false, error: pr.error, message: pr.message }); + 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); diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index 7c6e1131..d874d3d5 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -812,7 +812,12 @@ export const steps = [ { name: 'Номенклатура', type: 'CatalogRef.Номенклатура', title: 'Номенклатура' }, { name: 'Цена', type: 'Number(15,2)', title: 'Цена' }, { name: 'Картинка', type: 'Boolean', title: 'Картинка' }, + // Строковая колонка-выбор-из-списка: значение выбирается обработчиком НачалоВыбора + // через СписокТипов.ПоказатьВыборЭлемента (как колонка Тип в типовой Консоли запросов). + { name: 'ТипЗначения', type: 'String', title: 'Тип значения' }, ]}, + // Список значений для программного выбора (ПоказатьВыборЭлемента). + { name: 'СписокТипов', type: 'ValueList' }, ], elements: [ { table: 'Дерево', path: 'Дерево', initialTreeView: 'ExpandTopLevel', changeRowSet: true, @@ -824,6 +829,9 @@ export const steps = [ { picField: 'ДеревоКартинка', path: 'Дерево.Картинка', title: 'Картинка', valuesPicture: 'StdPicture.Favorites', loadTransparent: true }, // CheckBoxField на тот же булев — для кросс-проверки состояния картинки. { check: 'ДеревоКартинкаФлаг', path: 'Дерево.Картинка', title: 'Флаг' }, + // Поле-выбор-из-списка с кнопкой выбора и обработчиком НачалоВыбора. + { input: 'ДеревоТипЗначения', path: 'Дерево.ТипЗначения', title: 'Тип значения', + choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоТипЗначенияНачалоВыбора' } }, ]}, ], }, @@ -836,6 +844,10 @@ export const steps = [ content: `&НаСервере Процедура ПриСозданииНаСервере(Отказ, СтандартнаяОбработка) \tЗаполнитьУровень(Дерево.ПолучитьЭлементы(), Справочники.Номенклатура.ПустаяСсылка()); +\tСписокТипов.Добавить("Строка"); +\tСписокТипов.Добавить("Число"); +\tСписокТипов.Добавить("Дата"); +\tСписокТипов.Добавить("Булево"); КонецПроцедуры &НаСервере @@ -877,6 +889,24 @@ export const steps = [ \t\tТекущиеДанные.Картинка = НЕ ТекущиеДанные.Картинка; \tКонецЕсли; КонецПроцедуры + +&НаКлиенте +Процедура ДеревоТипЗначенияНачалоВыбора(Элемент, ДанныеВыбора, СтандартнаяОбработка) +\tСтандартнаяОбработка = Ложь; +\tОписаниеОповещения = Новый ОписаниеОповещения("ТипЗначенияЗавершениеВыбора", ЭтотОбъект); +\tСписокТипов.ПоказатьВыборЭлемента(ОписаниеОповещения, НСтр("ru = 'Выбрать тип'")); +КонецПроцедуры + +&НаКлиенте +Процедура ТипЗначенияЗавершениеВыбора(ВыбранныйЭлемент, ДополнительныеПараметры) Экспорт +\tЕсли ВыбранныйЭлемент = Неопределено Тогда +\t\tВозврат; +\tКонецЕсли; +\tТекущиеДанные = Элементы.Дерево.ТекущиеДанные; +\tЕсли ТекущиеДанные <> Неопределено Тогда +\t\tТекущиеДанные.ТипЗначения = ВыбранныйЭлемент.Значение; +\tКонецЕсли; +КонецПроцедуры `, }, diff --git a/tests/web-test/16-tree-form.test.mjs b/tests/web-test/16-tree-form.test.mjs index 27e47980..67f689e2 100644 --- a/tests/web-test/16-tree-form.test.mjs +++ b/tests/web-test/16-tree-form.test.mjs @@ -24,7 +24,7 @@ export default async function({ navigateLink, clickElement, closeForm, readTable await step('read-roots: на верхнем уровне видны группы (Товары, Услуги, БольшойСписок)', async () => { const t = await readTable('Дерево'); log(`columns=${t.columns?.join(',')} rows=${t.rows?.length}`); - assert.deepEqual(t.columns, ['Номенклатура', 'Цена', 'Картинка', 'Флаг'], 'колонки: Номенклатура + Цена + Картинка + Флаг'); + assert.deepEqual(t.columns, ['Номенклатура', 'Цена', 'Картинка', 'Флаг', 'Тип значения'], 'колонки: Номенклатура + Цена + Картинка + Флаг + Тип значения'); assert.equal(t.rows.length, 3, '3 корневые строки'); const names = t.rows.map(r => r['Номенклатура']); assert.includes(names, 'Товары', 'есть Товары'); @@ -61,6 +61,30 @@ export default async function({ navigateLink, clickElement, closeForm, readTable assert.equal(tovar01['Цена'], '1 500,00', 'Цена обновилась до 1 500,00'); }); + await step('choice-cell: fillTableRow задаёт ТипЗначения через форму выбора (НачалоВыбора)', async () => { + // Колонка-строка с кнопкой выбора + обработчиком НачалоВыбора → СписокТипов.ПоказатьВыборЭлемента + // («Выбрать тип»). Plain-paste тут не годится — движок открывает форму выбора и выбирает из списка. + const r = await fillTableRow({ ТипЗначения: 'Число' }, { row: 1 }); + log(`filled: ${JSON.stringify(r.filled)}`); + const cell = r.filled?.find(f => f.field === 'ТипЗначения'); + assert.ok(cell, 'поле ТипЗначения в результате'); + assert.equal(cell.ok, true, 'ok=true'); + assert.equal(cell.method, 'choice', 'method=choice (выбор из списка, не direct)'); + const t = await readTable('Дерево'); + const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01'); + assert.equal(tovar01['Тип значения'], 'Число', 'ТипЗначения = Число'); + }); + + await step('choice-cell-negative: несуществующий тип → ok:false/not_found (форма не закрывается)', async () => { + // not_found гасит только диалог выбора типа (умный dismiss), исходная форма остаётся — + // следующие шаги (picture) это подтверждают. + const r = await fillTableRow({ ТипЗначения: 'Нетакоготипа' }, { row: 1 }); + const cell = r.filled?.find(f => f.field === 'ТипЗначения'); + assert.ok(cell, 'поле ТипЗначения в результате'); + assert.equal(cell.ok, false, 'ok=false для несуществующего типа'); + assert.equal(cell.error, 'not_found', 'error=not_found'); + }); + await step('picture: колонка-картинка (pic:0/\'\') + кросс-проверка чекбоксом', async () => { const t = await readTable('Дерево'); const t15 = t.rows.find(r => r['Номенклатура'] === 'Товар 15'); // Цена 1500 > 1000 → иконка