diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 3d7f59c4..f6a973ed 100644 --- a/.claude/skills/web-test/scripts/dom/grid.mjs +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -1,4 +1,4 @@ -// web-test dom/grid v1.9 — grid resolution + table reading + edit-time helpers +// web-test dom/grid v1.10 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** @@ -405,14 +405,27 @@ export function getSelectedOrLastRowIndexScript(gridSelector) { } /** - * Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive, - * NBSP-normalised). Match order: exact → startsWith → includes. + * Scan a selection-form grid for the row matching `search` and return a click + * point INSIDE that row's first visible text cell — NOT the row-line centre. + * (A wide multi-column row's centre `x = r.x + r.width/2` lands beyond the form's + * horizontal viewport, on an overlay, so `mouse.click` misses the row → Enter + * doesn't select → form stays open. That was the `not_selectable` bug.) * - * When `searchLower` is empty, returns coords of the first row (fallback). + * `search` is either: + * - a string — matched per-cell (case/ё/NBSP-insensitive), preferring + * exact-cell → startsWith → includes (so "Кабель" wins over "Кабель ВВГ"); + * - an object `{ column: value, ... }` — each key fuzzy-resolved to a header + * column, a row matches when EVERY column's cell includes its value (AND), + * preferring rows where every column's cell equals its value exactly. + * Empty `search` → first row (fallback). * - * Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`. + * Returns: + * `{ rowCount, x, y, isGroup, matchKind, visibleSample }` when found, + * `{ rowCount, visibleSample, error? }` when rows present but unmatched, + * `{ rowCount: 0 }` for an empty grid, or `null` when no grid. + * `visibleSample` = first-cell text of visible rows, for actionable error messages. */ -export function scanGridRowsScript(formNum, searchLower) { +export function scanGridRowsScript(formNum, search) { return `(() => { const p = 'form${formNum}_'; const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); @@ -421,22 +434,102 @@ export function scanGridRowsScript(formNum, searchLower) { if (!body) return null; const lines = [...body.querySelectorAll('.gridLine')]; if (!lines.length) return { rowCount: 0 }; - const searchLower = ${JSON.stringify(searchLower || '')}; - let sel = null; - if (searchLower) { - const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); - const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) })); - sel = rowData.find(r => r.text === searchLower)?.el - || rowData.find(r => r.text.startsWith(searchLower))?.el - || rowData.find(r => r.text.includes(searchLower))?.el; + + const search = ${JSON.stringify(search ?? '')}; + const isObj = search && typeof search === 'object'; + const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); + const disp = s => (s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + const cellText = b => (b.querySelector('.gridBoxText') ? b.querySelector('.gridBoxText').innerText : b.innerText) || ''; + const visCells = line => [...line.children].filter(b => b.offsetWidth > 0); + const visibleSample = lines.slice(0, 10) + .map(l => disp(l.querySelector('.gridBoxText') ? l.querySelector('.gridBoxText').innerText : '')) + .filter(Boolean); + + let sel = null, matchKind = null; + + if (!search || (isObj && !Object.keys(search).length)) { + sel = lines[0]; matchKind = 'first'; + } else if (isObj) { + // Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol. + const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead'); + const headers = [...(headLine ? headLine.children : [])] + .filter(c => c.offsetWidth > 0) + .map(c => { + const t = (c.querySelector('.gridBoxText') || c).innerText || ''; + const title = c.getAttribute('title') || ''; + const r = c.getBoundingClientRect(); + return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width }; + }) + .filter(h => h.name); + const resolveCol = name => { + const n = norm(name); + const cand = h => [h.text, h.title].filter(Boolean); + return headers.find(h => cand(h).some(t => norm(t) === n)) + || headers.find(h => cand(h).some(t => norm(t).includes(n))); + }; + const cellAtCol = (line, col) => visCells(line).find(b => { + const r = b.getBoundingClientRect(); + const cx = r.x + r.width / 2; + return cx >= col.x && cx < col.right; + }); + const keys = Object.keys(search); + const cols = {}; + for (const k of keys) { + const c = resolveCol(k); + if (!c) return { rowCount: lines.length, error: 'filter_column_not_found', column: k, visibleSample }; + cols[k] = c; + } + let bestRank = 0; + for (const line of lines) { + let allIncludes = true, allExact = true; + for (const k of keys) { + const v = norm(search[k]); + if (!v) continue; + const cell = cellAtCol(line, cols[k]); + const t = norm(cell ? cellText(cell) : ''); + if (!t.includes(v)) { allIncludes = false; break; } + if (t !== v) allExact = false; + } + if (!allIncludes) continue; + const rank = allExact ? 2 : 1; + if (rank > bestRank) { bestRank = rank; sel = line; matchKind = allExact ? 'object-exact' : 'object'; if (rank === 2) break; } + } } else { - sel = lines[0]; // empty search → first row + // String: per-cell, prefer exact-cell → startsWith → includes. + const v = norm(search); + let bestRank = 0; + for (const line of lines) { + let rowRank = 0; + for (const b of visCells(line)) { + const t = norm(cellText(b)); + if (!t) continue; + let r = 0; + if (t === v) r = 3; else if (t.startsWith(v)) r = 2; else if (t.includes(v)) r = 1; + if (r > rowRank) rowRank = r; + } + if (rowRank > bestRank) { bestRank = rowRank; sel = line; matchKind = rowRank === 3 ? 'exact' : rowRank === 2 ? 'startsWith' : 'includes'; if (rowRank === 3) break; } + } } - if (!sel) return null; + + if (!sel) return { rowCount: lines.length, visibleSample }; + + // Click point: first visible text cell of the row (mirror findFocusCellScript) + // — skip checkboxes; on tree grids skip the first (expand-toggle) column. + // Clamp X near the left so a wide first column still lands in the viewport. + const isTree = !!body.querySelector('.gridBoxTree'); + let cells = visCells(sel).map(b => ({ r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox'), hasText: !!b.querySelector('.gridBoxText') })); + if (isTree && cells.length > 1) cells = cells.slice(1); + const pick = cells.find(c => !c.checkbox && c.hasText) || cells.find(c => !c.checkbox) || cells[0]; + if (!pick) return { rowCount: lines.length, visibleSample }; + const imgBox = sel.querySelector('.gridBoxImg'); const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false; - const r = sel.getBoundingClientRect(); - return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup }; + return { + rowCount: lines.length, + x: Math.round(pick.r.x + Math.min(pick.r.width / 2, 60)), + y: Math.round(pick.r.y + pick.r.height / 2), + isGroup, matchKind, visibleSample + }; })()`; } 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 c8aabbd2..c553f4a9 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.24 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// web-test forms/select-value v1.25 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -25,12 +25,13 @@ import { getFormState } from './state.mjs'; import { filterList } from '../table/filter.mjs'; /** - * Scan visible grid rows for a text match (exact → startsWith → includes). - * Returns center coords of the matched row, or null if not found. - * When searchLower is empty, returns coords of the first row (fallback). + * Scan a selection-form grid for the row matching `search` (string, or a + * { column: value } object for per-column matching) and return a click point + * inside the matched row's first visible text cell. See scanGridRowsScript for + * matching rules and the return shape (`{ x, y, isGroup, visibleSample, ... }`). */ -async function scanGridRows(formNum, searchLower) { - return page.evaluate(scanGridRowsScript(formNum, searchLower)); +async function scanGridRows(formNum, search) { + return page.evaluate(scanGridRowsScript(formNum, search)); } /** @@ -140,30 +141,44 @@ async function advancedSearchInline(formNum, text) { * @returns {{ field, ok, method }} or {{ field, error, message }} */ export async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { + const isObj = !!search && typeof search === 'object'; + // searchText (joined for objects) is only for the paste-based search steps + // (advancedSearchInline / simple search). Row matching uses the structured + // `search` via scanGridRows — no lossy join there. const searchText = typeof search === 'string' - ? search : (search ? Object.values(search).join(' ') : ''); + ? search : (isObj ? Object.values(search).join(' ') : ''); const searchLower = normYo((searchText || '').toLowerCase()); + const hasSearch = isObj ? Object.keys(search).length > 0 : !!searchLower; - // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group). + // Helper: try to select a row; returns result if ok, null if it couldn't be + // selected (real group row, or the click missed). Remembers why for the + // final error message. let hadUnselectableMatch = false; + let lastIsGroup = false; + let lastSample = null; async function trySelect(row) { const r = await dblclickAndVerify(row, selFormNum, fieldName); if (r.ok) return r; - hadUnselectableMatch = true; // found match but couldn't select (possibly group row or overlay) + hadUnselectableMatch = true; // matched but form stayed open (group row or missed click) + lastIsGroup = !!row.isGroup; return null; // form still open, try next step } + // Run scanGridRows, remember the visible-row sample for actionable errors. + async function scanAndTry(searchArg) { + const row = await scanGridRows(selFormNum, searchArg); + if (row?.visibleSample) lastSample = row.visibleSample; + if (row?.x) return trySelect(row); + return null; + } // Step 1: Scan visible rows (no filtering) - if (searchLower) { - const row = await scanGridRows(selFormNum, searchLower); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } + if (hasSearch) { + const r = await scanAndTry(search); + if (r) return r; } // Step 2: Advanced search (Alt+F — fast, no overlay issues) - if (typeof search === 'object' && search) { + if (isObj) { // Per-field advanced search via filterList(val, {field}) for (const [fld, val] of Object.entries(search)) { try { @@ -180,12 +195,9 @@ export async function pickFromSelectionForm(selFormNum, fieldName, search, origF // Inline advanced search (Alt+F, "по части строки") await advancedSearchInline(selFormNum, searchText); } - if (searchLower) { - const row = await scanGridRows(selFormNum, searchLower); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } + if (hasSearch) { + const r = await scanAndTry(search); + if (r) return r; } // Step 3: Fallback — simple search via search input (for forms without Alt+F support) @@ -201,32 +213,33 @@ export async function pickFromSelectionForm(selFormNum, fieldName, search, origF await page.keyboard.press('Enter'); await waitForStable(selFormNum); } catch { /* proceed */ } - const row = await scanGridRows(selFormNum, searchLower); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } + const r = await scanAndTry(search); + if (r) return r; } } // Step 4: Empty search → pick first row; otherwise not found - if (!searchLower) { - const row = await scanGridRows(selFormNum, ''); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } + if (!hasSearch) { + const r = await scanAndTry(''); + if (r) return r; } await page.keyboard.press('Escape'); await waitForStable(); const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); + const candidates = lastSample && lastSample.length ? ' Visible rows: ' + lastSample.join(', ') + '.' : ''; if (hadUnselectableMatch) { + if (lastIsGroup) { + return { field: fieldName, error: 'not_selectable', + message: 'Found ' + searchDesc + ' in selection form but it is a non-selectable group/folder row' }; + } + // Matched a row but the selection click didn't take — the value isn't in the + // visible result. Tell the caller to refine rather than blame a "group". return { field: fieldName, error: 'not_selectable', - message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' }; + message: 'Matched ' + searchDesc + ' but the row could not be selected (not in the visible result — refine the search).' + candidates }; } return { field: fieldName, error: 'not_found', - message: 'No matches in selection form for ' + searchDesc }; + message: 'No matches in selection form for ' + searchDesc + '.' + candidates }; } /** diff --git a/tests/skills/integration/build-webtest-config.test.mjs b/tests/skills/integration/build-webtest-config.test.mjs index f28dce6e..dac098cd 100644 --- a/tests/skills/integration/build-webtest-config.test.mjs +++ b/tests/skills/integration/build-webtest-config.test.mjs @@ -31,6 +31,16 @@ export const steps = [ { name: 'Телефон', type: 'String', length: 20 }, { name: 'Адрес', type: 'String', length: 200 }, { name: 'КодКПП', type: 'String', length: 9 }, + // Доп. строковые реквизиты — выводятся в широкую ФОРМУ ВЫБОРА (ниже), + // чтобы строка формы выбора стала шире окна выбора. Регресс бага + // «центр широкой строки уезжает за вьюпорт → клик мимо» (04-selectvalue). + { name: 'Регион', type: 'String', length: 50 }, + { name: 'Город', type: 'String', length: 50 }, + { name: 'Улица', type: 'String', length: 100 }, + { name: 'БИК', type: 'String', length: 9 }, + { name: 'ОГРН', type: 'String', length: 13 }, + { name: 'ОКПО', type: 'String', length: 10 }, + { name: 'ВидДеятельности', type: 'String', length: 100 }, ], }, args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' }, @@ -311,6 +321,11 @@ export const steps = [ \tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Юг", "7700000002")); \tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Восток", "7700000003")); \tСписок.Добавить(Новый Структура("Имя,ИНН", "АО Запад", "7700000004")); +\t// Контрагент с именем ровно «Север» рядом с «ООО Север» — для детерминированного +\t// регресса широкой формы выбора (04-selectvalue): поиск «Север» даёт 2 вхождения, +\t// «ООО Север» сортируется раньше. Багованный клик-по-центру/эскалация выберут +\t// «ООО Север»; фикс через exact-preference обязан выбрать точное «Север». +\tСписок.Добавить(Новый Структура("Имя,ИНН", "Север", "7700000005")); \tДля Каждого Запись Из Список Цикл \t\tЭлемент = Справочники.Контрагенты.СоздатьЭлемент(); \t\tЭлемент.Наименование = Запись.Имя; @@ -572,6 +587,50 @@ export const steps = [ validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Контрагенты/Forms/ФормаСписка/Ext/Form.xml' }, }, + // Форма ВЫБОРА Контрагенты — НАМЕРЕННО ШИРОКАЯ (14 колонок), чтобы строка была + // шире окна выбора. Регресс бага «центр широкой строки уезжает за вьюпорт → + // клик в оверлей → not_selectable» (04-selectvalue/direct-form, выбор «Север»). + // form-add с Purpose=Choice авто-назначает её DefaultChoiceForm → именно она + // открывается при выборе ссылки на Контрагента. + { + name: 'form-add: Форма выбора Контрагенты', + script: 'form-add/scripts/form-add', + args: { '-ObjectPath': '{workDir}/Catalogs/Контрагенты.xml', '-FormName': 'ФормаВыбора', '-Purpose': 'Choice' }, + }, + { + name: 'form-compile: Форма выбора Контрагенты (широкая)', + script: 'form-compile/scripts/form-compile', + input: { + title: 'Выбор контрагента', + attributes: [ + { name: 'Список', type: 'DynamicList', main: true, + settings: { mainTable: 'Catalog.Контрагенты', dynamicDataRead: true } }, + ], + elements: [ + // choiceMode: true → true на таблице: Enter/двойной + // клик ПОДТВЕРЖДАЮТ выбор (а не открывают элемент). Без него форма ведёт + // себя как обычный список (Enter открывает элемент). + { table: 'Список', path: 'Список', choiceMode: true, columns: [ + { input: 'Code', path: 'Список.Code', title: 'Код' }, + { input: 'Description', path: 'Список.Description', title: 'Наименование' }, + { input: 'ИНН', path: 'Список.ИНН', title: 'ИНН' }, + { input: 'Телефон', path: 'Список.Телефон', title: 'Телефон' }, + { input: 'Адрес', path: 'Список.Адрес', title: 'Адрес' }, + { input: 'КодКПП', path: 'Список.КодКПП', title: 'КПП' }, + { input: 'Регион', path: 'Список.Регион', title: 'Регион' }, + { input: 'Город', path: 'Список.Город', title: 'Город' }, + { input: 'Улица', path: 'Список.Улица', title: 'Улица' }, + { input: 'БИК', path: 'Список.БИК', title: 'БИК' }, + { input: 'ОГРН', path: 'Список.ОГРН', title: 'ОГРН' }, + { input: 'ОКПО', path: 'Список.ОКПО', title: 'ОКПО' }, + { input: 'ВидДеятельности', path: 'Список.ВидДеятельности', title: 'Вид деятельности' }, + ]}, + ], + }, + args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Контрагенты/Forms/ФормаВыбора/Ext/Form.xml' }, + validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Контрагенты/Forms/ФормаВыбора/Ext/Form.xml' }, + }, + // Форма элемента Номенклатура — 2 вкладки, все типы полей { name: 'form-add: Форма элемента Номенклатура', diff --git a/tests/web-test/04-selectvalue.test.mjs b/tests/web-test/04-selectvalue.test.mjs index 592523bc..8cc5a27f 100644 --- a/tests/web-test/04-selectvalue.test.mjs +++ b/tests/web-test/04-selectvalue.test.mjs @@ -22,18 +22,26 @@ export default async function({ navigateSection, openCommand, clickElement, sele await closeForm({ save: false }); }); - await step('direct-form: Контрагент → CatalogRef.Контрагенты (quickChoice=false)', async () => { + await step('direct-form: Контрагент → форма выбора (ШИРОКАЯ — регресс центр-X + exact-preference)', async () => { + // Форма выбора Контрагентов намеренно широкая (14 колонок) — строка шире окна. + // Старый scanGridRows целился в ЦЕНТР строки → клик в оверлей за вьюпортом → + // не та строка / not_selectable. Новый — в первую видимую ячейку. + // В справочнике есть и «ООО Север», и ровно «Север»; поиск «Север» даёт 2 + // вхождения, «ООО Север» сортируется раньше. Багованный путь выбрал бы «ООО + // Север»; фикс (exact-preference + клик в видимую ячейку) обязан выбрать «Север». await navigateSection('Склад'); await openCommand('Приходная накладная'); await clickElement('Создать'); const result = await selectValue('Контрагент', 'Север'); - log(`method=${result.selected?.method}, search=${result.selected?.search}`); + log(`method=${result.selected?.method}, search=${result.selected?.search}, err=${result.selected?.error || ''}`); assert.equal(result.selected?.method, 'form', 'Должен быть метод form (через форму выбора)'); + assert.ok(!result.selected?.error, `выбор без ошибки (было not_selectable): ${result.selected?.message || ''}`); const field = findField(result, 'Контрагент'); log(`Контрагент value='${field?.value}'`); - assert.includes(field?.value || '', 'Север', 'Контрагент должен показать выбранное значение'); + assert.equal((field?.value || '').trim(), 'Север', + 'exact-preference + клик в видимую ячейку: выбран точный «Север», не «ООО Север»'); await closeForm({ save: false }); }); diff --git a/tests/web-test/05-table.test.mjs b/tests/web-test/05-table.test.mjs index c5b1f3a8..59688c69 100644 --- a/tests/web-test/05-table.test.mjs +++ b/tests/web-test/05-table.test.mjs @@ -120,4 +120,30 @@ export default async function({ navigateSection, openCommand, clickElement, fill assert.equal(t.rows[0]['Номенклатура'], 'Товар 03', 'Удалена первая (Товар 02), осталась Товар 03'); await closeForm({ save: false }); }); + + await step('composite-wide-form: ИсточникТЧ {value,type} через ШИРОКУЮ форму выбора', async () => { + // Прямой регресс исходного симптома: fillTableRow в составную ячейку → диалог + // типа → широкая форма выбора Контрагентов (14 колонок, строка шире окна). + // Старый scanGridRows целился в центр строки → клик в оверлей → not_selectable. + // Детерминированность: «ООО Север» сортируется раньше точного «Север»; багованный + // путь выбрал бы «ООО Север», фикс (exact-preference + клик в видимую ячейку) — «Север». + await navigateSection('Склад'); + await openCommand('Приходная накладная'); + await clickElement('Создать'); + + const r = await fillTableRow( + { 'Источник': { value: 'Север', type: 'Контрагент' } }, + { table: 'Товары', add: true } + ); + log(`composite fill: ${JSON.stringify(r.filled)}`); + const item = (r.filled || []).find(f => /сточник/.test(f.field || '')); + assert.ok(item?.ok, `ячейка Источник заполнена без ошибки: ${item?.error || ''} ${item?.message || ''}`); + + const t = await readTable({ table: 'Товары' }); + log(`Источник cell='${t.rows[0]?.['Источник']}'`); + assert.equal(t.rows[0]?.['Источник'], 'Север', + 'exact-preference + клик в видимую ячейку: выбран точный «Север», не «ООО Север»'); + + await closeForm({ save: false }); + }); }