From 80ffed9a2897c6197356a3f9a515a2d624b4b9f8 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 1 Jun 2026 19:14:36 +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=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D1=83=D1=8E=20?= =?UTF-8?q?=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D1=83-=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BF=D1=80=D1=8F=D0=BC=D1=8B=D0=BC=20=D0=B2=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ячейка грида с кнопкой выбора (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) --- .../scripts/engine/table/row-fill.mjs | 151 ++++++++++-------- .../integration/build-webtest-config.test.mjs | 18 ++- tests/web-test/16-tree-form.test.mjs | 24 ++- 3 files changed, 125 insertions(+), 68 deletions(-) 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 80561650..7cd2d6a5 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.20 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// web-test table/row-fill v1.21 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -30,6 +30,75 @@ import { } 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 }; + } + // Try direct input; poll for the input value to settle on the pasted text (editable cell). + await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); + let after = before, stuck = false; + for (let i = 0; i < 6; i++) { + await page.waitForTimeout(100); + after = await page.evaluate(`document.activeElement?.value || ''`); + if (after !== before && norm(after).includes(norm(text))) { stuck = true; break; } + } + if (stuck) return { ok: true, method: 'direct', value: text }; + + // 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) { + 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. @@ -306,23 +375,16 @@ 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. + // 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') { - 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) + 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'] }); @@ -559,57 +621,16 @@ 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. + // 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') { - 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); + const r = await fillChoiceCell(formNum, text, { type: info.type, fieldLabel: matchedKey }); 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 }); + 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; diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index d874d3d5..b2b66d65 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -815,6 +815,9 @@ export const steps = [ // Строковая колонка-выбор-из-списка: значение выбирается обработчиком НачалоВыбора // через СписокТипов.ПоказатьВыборЭлемента (как колонка Тип в типовой Консоли запросов). { name: 'ТипЗначения', type: 'String', title: 'Тип значения' }, + // Редактируемая строковая колонка: у поля есть кнопка выбора, но НачалоВыбора пустой + // (F4 ничего не открывает), текст вводится напрямую — модель ячейки «Значение» Консоли запросов. + { name: 'РедактируемаяСтрока', type: 'String', title: 'Редактируемая строка' }, ]}, // Список значений для программного выбора (ПоказатьВыборЭлемента). { name: 'СписокТипов', type: 'ValueList' }, @@ -830,8 +833,14 @@ export const steps = [ // CheckBoxField на тот же булев — для кросс-проверки состояния картинки. { check: 'ДеревоКартинкаФлаг', path: 'Дерево.Картинка', title: 'Флаг' }, // Поле-выбор-из-списка с кнопкой выбора и обработчиком НачалоВыбора. - { input: 'ДеревоТипЗначения', path: 'Дерево.ТипЗначения', title: 'Тип значения', + // textEdit:false — ручной ввод запрещён (как у колонки «Тип» Консоли запросов): + // вставленный текст отвергается, значение задаётся только через форму выбора по F4. + { input: 'ДеревоТипЗначения', path: 'Дерево.ТипЗначения', title: 'Тип значения', textEdit: false, choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоТипЗначенияНачалоВыбора' } }, + // Поле с кнопкой выбора, но пустым НачалоВыбора (СтандартнаяОбработка=Ложь): + // кнопка iCB есть, F4 ничего не открывает, текст редактируется напрямую (модель «Значение»). + { input: 'ДеревоРедактируемаяСтрока', path: 'Дерево.РедактируемаяСтрока', title: 'Редактируемая строка', + choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоРедактируемаяСтрокаНачалоВыбора' } }, ]}, ], }, @@ -907,6 +916,13 @@ export const steps = [ \t\tТекущиеДанные.ТипЗначения = ВыбранныйЭлемент.Значение; \tКонецЕсли; КонецПроцедуры + +&НаКлиенте +Процедура ДеревоРедактируемаяСтрокаНачалоВыбора(Элемент, ДанныеВыбора, СтандартнаяОбработка) +\t// Пустой обработчик: кнопка выбора есть, но F4 ничего не открывает. +\t// Текст вводится напрямую — модель ячейки «Значение» типовой Консоли запросов. +\tСтандартнаяОбработка = Ложь; +КонецПроцедуры `, }, diff --git a/tests/web-test/16-tree-form.test.mjs b/tests/web-test/16-tree-form.test.mjs index 67f689e2..e2913804 100644 --- a/tests/web-test/16-tree-form.test.mjs +++ b/tests/web-test/16-tree-form.test.mjs @@ -11,6 +11,9 @@ export const timeout = 90000; // Покрывает: 05-table/edit-form (fillTableRow method:'direct' на FormDataTree-колонке) // + 08-hierarchy/tree-edit (expand узла + edit Цены внутри expanded группы) // + readTable picture-колонки (pic:N/'') и Selection-toggle. +// + дискриминатор choice-ячейки (fillChoiceCell): ДеревоРедактируемаяСтрока (кнопка iCB, +// пустой НачалоВыбора, текст редактируется → method:'direct') vs ДеревоТипЗначения +// (РедактированиеТекста=Ложь, текст отвергается → форма выбора, method:'choice'). export default async function({ navigateLink, clickElement, closeForm, readTable, fillTableRow, assert, step, log }) { @@ -24,7 +27,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,9 +64,26 @@ export default async function({ navigateLink, clickElement, closeForm, readTable assert.equal(tovar01['Цена'], '1 500,00', 'Цена обновилась до 1 500,00'); }); + await step('choice-direct: редактируемая choice-ячейка заполняется прямым вводом (method:direct)', async () => { + // ДеревоРедактируемаяСтрока — поле с кнопкой выбора (iCB), но пустым НачалоВыбора и + // РедактированиеТекста=Истина: текст ПРИЛИПАЕТ. fillChoiceCell определяет это поведенчески + // (paste прилип → stuck) и вводит напрямую, не уходя в форму. Модель ячейки «Значение» + // типовой Консоли запросов (была баг no_selection_form). + const r = await fillTableRow({ 'Редактируемая строка': 'привет' }, { row: 1 }); + log(`filled: ${JSON.stringify(r.filled)}`); + const cell = r.filled?.find(f => /Редактируем/i.test(f.field)); + assert.ok(cell, 'поле Редактируемая строка в результате'); + assert.equal(cell.ok, true, 'ok=true'); + assert.equal(cell.method, 'direct', 'method=direct (прямой ввод, форма не открывалась)'); + const t = await readTable('Дерево'); + const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01'); + assert.equal(tovar01['Редактируемая строка'], 'привет', 'значение введено напрямую'); + }); + await step('choice-cell: fillTableRow задаёт ТипЗначения через форму выбора (НачалоВыбора)', async () => { // Колонка-строка с кнопкой выбора + обработчиком НачалоВыбора → СписокТипов.ПоказатьВыборЭлемента - // («Выбрать тип»). Plain-paste тут не годится — движок открывает форму выбора и выбирает из списка. + // («Выбрать тип»), РедактированиеТекста=Ложь. Прямой ввод ОТВЕРГАется — fillChoiceCell видит + // stuck=false и открывает форму выбора, выбирая из списка (method:choice, не direct). const r = await fillTableRow({ ТипЗначения: 'Число' }, { row: 1 }); log(`filled: ${JSON.stringify(r.filled)}`); const cell = r.filled?.find(f => f.field === 'ТипЗначения');