From b1187865ecbf881f7d61f0322c1c18cc91b20941 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 4 Mar 2026 22:18:27 +0300 Subject: [PATCH] feat(web-test): support composite-type fields in fillTableRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cells accepting multiple types (e.g. subconto on account 80.01) now handled via { value, type } syntax. Paste rejection detected automatically — F4 opens type dialog, picks type, then selects value from the catalog form. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/SKILL.md | 7 +- .claude/skills/web-test/scripts/browser.mjs | 132 +++++++++++++++++++- docs/web-test-guide.md | 2 +- 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 53364dfa..3dc761f1 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -248,7 +248,7 @@ await selectValue('Документ', '0000-000601', { type: 'Реализаци Also supports DCS labels — auto-enables the paired checkbox. #### `fillTableRow(fields, opts)` → form state -Fill table row cells via Tab navigation. +Fill table row cells via Tab navigation. Value is a plain string or `{ value, type }` for composite-type cells. ```js // Add new row: @@ -261,6 +261,11 @@ await fillTableRow( { 'Количество': '20' }, { tab: 'Товары', row: 0 } ); +// Composite-type cell (e.g. SubConto accepting multiple types): +await fillTableRow( + { 'СубконтоКт1': { value: 'Голованов', type: 'Физическое лицо' } }, + { tab: 'Проводки' } +); ``` - Tab-based sequential navigation — field order set by 1C form config diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 0d0a801c..21deeb7e 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -1981,7 +1981,11 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { // 4. Prepare pending fields for fuzzy matching const pending = new Map(); for (const [key, val] of Object.entries(fields)) { - pending.set(key, { value: String(val), filled: false }); + 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 = []; @@ -2005,7 +2009,7 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { return { tag: f.tagName || 'none' }; })()`); - if (cell.tag !== 'INPUT') { + if (cell.tag !== 'INPUT' || !cell.fullName) { // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) nonInputCount++; if (nonInputCount > 3) break; // truly exited edit mode @@ -2056,6 +2060,76 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { await page.keyboard.press('Control+V'); 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) { + // Paste rejected — cell requires type selection first via F4 + if (info.type) { + await page.keyboard.press('F4'); + await page.waitForTimeout(1000); + const typeForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (typeForm !== null && await isTypeDialog(typeForm)) { + await pickFromTypeDialog(typeForm, info.type); + await waitForStable(typeForm); + // After type selection, the selection form should open + const selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'no_selection_form', + message: `After selecting type "${info.type}", no selection form opened` }); + 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, + 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, + error: 'paste_rejected', + message: `Cell "${matchedKey}" rejected text input. F4 did not open type dialog` }); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } else { + // No type specified — can't fill this composite-type cell + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + 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 eddItems = await page.evaluate(`(() => { const edd = document.getElementById('editDropDown'); @@ -2209,11 +2283,11 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { continue; } - // Check for a new selection form (reference field opened selection) + // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs) const newForm = await page.evaluate(`(() => { const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; const m = el.id.match(/^form(\\d+)_/); if (m) forms[m[1]] = true; }); @@ -2222,7 +2296,53 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { })()`); if (newForm !== null) { - // Selection form opened — search and pick + 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 page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'no_selection_form', + message: `After selecting type "${info.type}", no selection form opened` }); + 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, + 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, + 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 diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md index 8986d5b1..507bebee 100644 --- a/docs/web-test-guide.md +++ b/docs/web-test-guide.md @@ -237,7 +237,7 @@ await closeForm({ save: false }); | `clickElement(text, {dblclick?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия из списка | form state или `{ submenu }` | | `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры) | `{ filled: [{field, ok, method}], form }` | | `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст или `{поле: значение}`. `{ type }` для составного типа | form state с `selected` | -| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку таблицы. `add: true` = новая, `row: N` = редактирование | form state | +| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка или `{ value, type }` для составного типа | form state | | `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state | | `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да" | form state | | `filterList(text, {field?, exact?})` | Фильтр списка. Без field = все колонки, с field = расширенный поиск | form state |