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 1a105f6e..26387d50 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.22 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// web-test table/row-fill v1.23 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -58,15 +58,46 @@ async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } = 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). + // 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, stuck = false; + 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 && norm(after).includes(norm(text))) { stuck = true; break; } + 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 }; } - 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. @@ -79,6 +110,15 @@ async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } = 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)) { @@ -681,14 +721,28 @@ export async function fillTableRow(fields, { tab, add, row, table, scroll } = {} 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) pick = realItems[0]; - // 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*$/, '') }); + 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'); diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index c464863c..f28dce6e 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -818,6 +818,14 @@ export const steps = [ // Редактируемая строковая колонка: у поля есть кнопка выбора, но НачалоВыбора пустой // (F4 ничего не открывает), текст вводится напрямую — модель ячейки «Значение» Консоли запросов. { name: 'РедактируемаяСтрока', type: 'String', title: 'Редактируемая строка' }, + // Редактируемые choice-ячейки Число и Дата (та же модель «Значение» КЗ): кнопка выбора + + // пустой НачалоВыбора, текст вводится напрямую и ПЕРЕФОРМАТИРУЕТСЯ маск-инпутом + // (1234.56 → «1 234,56»). Регресс-guard для fillChoiceCell — раньше includes-проверка + // рвалась о переформатирование → ложное F4 → калькулятор. + { name: 'РедактируемоеЧисло', type: 'Number(15,2)', title: 'Редактируемое число' }, + { name: 'РедактируемаяДата', type: 'date', title: 'Редактируемая дата' }, + // Булева колонка-флажок (отдельно от Картинка) — для fillTableRow toggle на дереве. + { name: 'Булево', type: 'Boolean', title: 'Булево' }, ]}, // Список значений для программного выбора (ПоказатьВыборЭлемента). { name: 'СписокТипов', type: 'ValueList' }, @@ -841,6 +849,17 @@ export const steps = [ // кнопка iCB есть, F4 ничего не открывает, текст редактируется напрямую (модель «Значение»). { input: 'ДеревоРедактируемаяСтрока', path: 'Дерево.РедактируемаяСтрока', title: 'Редактируемая строка', choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоРедактируемаяСтрокаНачалоВыбора' } }, + // Редактируемые choice-ячейки Число/Дата: кнопка iCB + пустой НачалоВыбора → текст + // редактируется напрямую, значение переформатируется маск-инпутом (модель «Значение» КЗ). + { input: 'ДеревоРедактируемоеЧисло', path: 'Дерево.РедактируемоеЧисло', title: 'Редактируемое число', + choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоРедактируемоеЧислоНачалоВыбора' } }, + { input: 'ДеревоРедактируемаяДата', path: 'Дерево.РедактируемаяДата', title: 'Редактируемая дата', + choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоРедактируемаяДатаНачалоВыбора' } }, + // Булево как ПОЛЕ ВВОДА с кнопкой выбора (не флажок): в ячейке выбор Да/Нет — + // fillTableRow идёт через dropdown-путь (как «Значение» типа Булево в Консоли + // запросов), не toggle. Кнопка iCB + пустой НачалоВыбора — единая модель «Значение». + { input: 'ДеревоБулево', path: 'Дерево.Булево', title: 'Булево', + choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоБулевоНачалоВыбора' } }, ]}, ], }, @@ -926,6 +945,25 @@ export const steps = [ \t// Текст вводится напрямую — модель ячейки «Значение» типовой Консоли запросов. \tСтандартнаяОбработка = Ложь; КонецПроцедуры + +&НаКлиенте +Процедура ДеревоРедактируемоеЧислоНачалоВыбора(Элемент, ДанныеВыбора, СтандартнаяОбработка) +\t// Пустой обработчик — число редактируется напрямую (модель «Значение» КЗ). +\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 3481fb11..a57f010a 100644 --- a/tests/web-test/16-tree-form.test.mjs +++ b/tests/web-test/16-tree-form.test.mjs @@ -14,6 +14,9 @@ export const timeout = 90000; // + дискриминатор choice-ячейки (fillChoiceCell): ДеревоРедактируемаяСтрока (кнопка iCB, // пустой НачалоВыбора, текст редактируется → method:'direct') vs ДеревоТипЗначения // (РедактированиеТекста=Ложь, текст отвергается → форма выбора, method:'choice'). +// + редактируемые choice-ячейки Число/Дата (РедактируемоеЧисло/РедактируемаяДата): маск-инпут +// переформатирует значение (1234.56 → «1 234,56») — регресс на баг с ложным F4→калькулятор. +// + булево как поле ввода (Булево, InputField, не флажок): выбор Да/Нет через dropdown-путь. export default async function({ navigateLink, clickElement, closeForm, readTable, fillTableRow, assert, step, log }) { @@ -27,7 +30,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, 'Товары', 'есть Товары'); @@ -119,6 +122,50 @@ export default async function({ navigateLink, clickElement, closeForm, readTable assert.equal(cell.error, 'not_found', 'error=not_found'); }); + await step('choice-number: редактируемая choice-ячейка Число — paste переформатируется, method:direct', async () => { + // РедактируемоеЧисло — Number-колонка с кнопкой выбора (iCB) и пустым НачалоВыбора (модель + // «Значение» КЗ). Маск-инпут ПЕРЕФОРМАТИРУЕТ «1234.56» → «1 234,56» (nbsp-разделитель, запятая). + // Регресс на баг, где includes-проверка рвалась о переформатирование → ложное F4 → калькулятор. + // Теперь дискриминатор поведенческий (инпут изменился + нет EDD → direct), формат не важен. + const r = await fillTableRow({ 'Редактируемое число': '1234.56' }, { 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 (числовой маск-инпут, без F4/калькулятора)'); + const t = await readTable('Дерево'); + const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01'); + // 1С web использует неразрывный пробел как разделитель разрядов — убираем все пробелы перед сравнением. + assert.equal((tovar01['Редактируемое число'] || '').replace(/[\s\u00A0]/g, ''), '1234,56', 'число переформатировано в 1234,56'); + }); + + await step('choice-date: редактируемая choice-ячейка Дата — method:direct', async () => { + // РедактируемаяДата — Date-колонка с кнопкой выбора и пустым НачалоВыбора. Та же модель «Значение». + const r = await fillTableRow({ 'Редактируемая дата': '15.06.2025' }, { 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['Редактируемая дата'], '15.06.2025', 'дата записана'); + }); + + await step('bool-input: булева ячейка-поле-ввода (не флажок) заполняется выбором Да', async () => { + // ДеревоБулево — InputField на булевом пути (НЕ CheckBoxField) с кнопкой выбора (iCB) и + // пустым НачалоВыбора: в ячейке выбор Да/Нет, fillTableRow идёт через dropdown-путь, а не + // toggle. Покрывает булево как поле ввода (модель «Значение» типа Булево в Консоли запросов). + 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'); + const t = await readTable('Дерево'); + const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01'); + assert.equal(tovar01['Булево'], 'Да', 'Булево = Да'); + }); + await step('picture: колонка-картинка (pic:0/\'\') + кросс-проверка чекбоксом', async () => { const t = await readTable('Дерево'); const t15 = t.rows.find(r => r['Номенклатура'] === 'Товар 15'); // Цена 1500 > 1000 → иконка