From 22bf17ee0023a8d585b655b3d521522888da55c6 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 4 Mar 2026 20:53:11 +0300 Subject: [PATCH] feat(web-test): support per-field search in selectValue via object syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectValue now accepts search as object { field: value } for per-field disambiguation in selection forms. All fields use advanced search (Alt+F) by specific column — more efficient than simple full-text search on large tables, and navigates to exact row even in virtual grids. Also fixes filterList modal cleanup: now checks specific dialog form instead of generic modalSurface, preventing accidental closure of parent modal forms (e.g. selection forms). Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/SKILL.md | 4 + .claude/skills/web-test/scripts/browser.mjs | 188 ++++++++------------ docs/web-test-guide.md | 2 +- 3 files changed, 83 insertions(+), 111 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 52863564..53364dfa 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -229,9 +229,13 @@ Method is one of: `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | #### `selectValue(field, search, opts?)` → form state with `selected` Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. +`search` — string for simple search, or `{ field: value }` object for per-field advanced search: ```js await selectValue('Организация', 'Конфетпром'); // result.selected = { field: 'Организация', search: 'Конфетпром', method: 'dropdown'|'form' } + +// Per-field search (disambiguate by multiple columns): +await selectValue('Документ', { 'Номер': '0000-000601', 'Дата': '29.12.2016' }, { type: 'Реализация (акт' }); ``` For **composite-type fields** (accepting multiple types), specify `type` to first select the type, then the value: diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 92bd8842..0d0a801c 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -739,59 +739,46 @@ export async function readSpreadsheet() { } /** - * Pick a value from an opened selection form: search + dblclick matching row. + * Pick a value from an opened selection form: filter + dblclick matching row. * * Strategy: - * 1. Find search input in selection form - * 2. Clipboard paste search text (trusted event, more reliable than page.fill) - * 3. Press Enter to apply search filter - * 4. Wait for grid to update, then score rows - * 5. Dblclick best match; if form persists (hit a folder), try Enter as fallback + * - string: simple search via filterList → dblclick first row + * - object: advanced search (Alt+F) for each field sequentially + * (searches by specific column, efficient on large tables) → dblclick positioned row * + * @param {number} selFormNum - selection form number + * @param {string} fieldName - field being filled (for error messages) + * @param {string|Object} search - string for simple search, or { field: value } for per-field search + * @param {number} origFormNum - original form number (to verify we returned) * @returns {{ field, ok, method }} or {{ field, error, message }} */ -async function pickFromSelectionForm(selFormNum, fieldName, text, origFormNum) { - // 1. Find search input in the selection form (strict — only named search fields, - // do NOT fall back to first input — it may be a filter field like ТипСоглашения) - const searchInputId = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - const inputs = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')].filter(el => el.offsetWidth > 0); - const searchInput = inputs.find(el => /поиск|search|строкапоиска|SearchString|find/i.test(el.id)); - return searchInput ? searchInput.id : null; - })()`) - - // 2. Fill search field via clipboard paste (more reliable than page.fill for 1C) - if (searchInputId && text) { - await page.click(`[id="${searchInputId}"]`); - await page.waitForTimeout(300); - // Select all existing text and replace with paste - await page.keyboard.press('Control+A'); - await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); - await page.keyboard.press('Control+V'); - await page.waitForTimeout(500); - // Apply search - await page.keyboard.press('Enter'); - // Wait for search results: loading indicator + grid row count stabilization - await waitForStable(); - // Extra: wait for grid content to settle (loader inside grid, async row fetch) - let gridStable = 0, lastRowCount = -1; - for (let i = 0; i < 15 && gridStable < 3; i++) { - await page.waitForTimeout(POLL_INTERVAL); - const rc = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); - if (!grid) return -1; - const loading = grid.querySelector('.loadingImage, .waitCurtain, .progressBar'); - if (loading && loading.offsetWidth > 0) return -2; - const body = grid.querySelector('.gridBody'); - return body ? body.querySelectorAll('.gridLine').length : 0; - })()`); - if (rc === -2) { gridStable = 0; continue; } // still loading - if (rc === lastRowCount) { gridStable++; } else { gridStable = 0; lastRowCount = rc; } +async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { + // 1. Apply filters based on search type + if (typeof search === 'string') { + // Simple text search + if (search) { + try { + await filterList(search); + } catch (e) { + await page.keyboard.press('Escape'); + await waitForStable(); + return { field: fieldName, error: 'filter_failed', message: e.message }; + } + } + } else if (search && typeof search === 'object') { + // Per-field advanced search (Alt+F) for each entry — searches by specific column, + // more efficient than simple search on large tables (no full-text scan across all columns). + const entries = Object.entries(search); + for (const [fld, val] of entries) { + try { + await filterList(String(val), { field: fld }); + } catch (e) { + // Advanced search failed — fall through and try with what we have + } } } - // 3. Read grid and find best matching row + // 2. Find the target row — currently selected (positioned by advanced search) or first const rowTarget = await page.evaluate(`(() => { const p = 'form${selFormNum}_'; const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); @@ -800,76 +787,56 @@ async function pickFromSelectionForm(selFormNum, fieldName, text, origFormNum) { if (!body) return null; const lines = [...body.querySelectorAll('.gridLine')]; if (!lines.length) return { rowCount: 0 }; - const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))}; - const ny = s => s.replace(/ё/gi, 'е'); - - // Score each row: exact cell match > row includes > partial cell match - let bestLine = null, bestScore = 0; - for (const line of lines) { - const boxes = [...line.querySelectorAll('.gridBoxText')].map(b => b.innerText?.trim() || ''); - const rowText = ny(boxes.join(' ').toLowerCase()); - let score = 0; - if (boxes.some(b => ny(b.toLowerCase()) === target)) score = 3; // exact cell match - else if (rowText === target) score = 3; // exact row match - else if (boxes.some(b => ny(b.toLowerCase()).includes(target))) score = 2; // cell includes target - else if (rowText.includes(target)) score = 2; // row includes target - else if (target.includes(ny(boxes[0]?.toLowerCase()))) score = 1; // target includes first cell - if (score > bestScore) { bestScore = score; bestLine = line; } - } - - // If search was applied and only 1 row — pick it even without text match - if (!bestLine && lines.length === 1) { - bestLine = lines[0]; bestScore = 1; - } - if (!bestLine || bestScore === 0) return { rowCount: lines.length, score: 0 }; - const r = bestLine.getBoundingClientRect(); - return { rowCount: lines.length, score: bestScore, + // Prefer selected/active row (positioned by advanced search), fall back to first + const sel = lines.find(l => l.classList.contains('select') || l.classList.contains('active')) || lines[0]; + const r = sel.getBoundingClientRect(); + return { rowCount: lines.length, matched: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); - if (rowTarget?.x && rowTarget.score > 0) { - // 4. Dblclick the matched row - await page.mouse.dblclick(rowTarget.x, rowTarget.y); + if (!rowTarget?.matched) { + await page.keyboard.press('Escape'); + await waitForStable(); + const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); + return { field: fieldName, error: 'not_found', + message: 'No matches in selection form for ' + searchDesc + + (rowTarget?.rowCount === 0 ? ' (grid empty)' : '') }; + } + + // 3. Dblclick the target row + await page.mouse.dblclick(rowTarget.x, rowTarget.y); + await waitForStable(selFormNum); + + // Verify selection form closed + const stillOpen = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (stillOpen) { + // Dblclick may have opened a folder — try Enter to select current row + await page.keyboard.press('Enter'); await waitForStable(selFormNum); - // Verify selection form closed - const stillOpen = await page.evaluate(`(() => { + // Still open? Close and report + const stillOpen2 = await page.evaluate(`(() => { const p = 'form${selFormNum}_'; return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); })()`); - if (stillOpen) { - // Dblclick may have opened a folder — try Enter to select current row - await page.keyboard.press('Enter'); - await waitForStable(selFormNum); - - // Still open? Close and report - const stillOpen2 = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (stillOpen2) { - await page.keyboard.press('Escape'); - await waitForStable(); - } + if (stillOpen2) { + await page.keyboard.press('Escape'); + await waitForStable(); } - - // Check for 1C error modals after selection - const err = await page.evaluate(checkErrorsScript()); - if (err?.modal) { - try { - const btn = await page.$('a.press.pressDefault'); - if (btn) { await btn.click(); await page.waitForTimeout(500); } - } catch { /* OK */ } - } - return { field: fieldName, ok: true, method: 'form' }; } - // 5. No matching row or grid empty — close and report error - await page.keyboard.press('Escape'); - await waitForStable(); - return { field: fieldName, error: 'not_found', - message: 'No matches in selection form for "' + text + '"' + - (rowTarget?.rowCount ? ' (' + rowTarget.rowCount + ' rows checked)' : ' (grid empty)') }; + // Check for 1C error modals after selection + const err = await page.evaluate(checkErrorsScript()); + if (err?.modal) { + try { + const btn = await page.$('a.press.pressDefault'); + if (btn) { await btn.click(); await page.waitForTimeout(500); } + } catch { /* OK */ } + } + return { field: fieldName, ok: true, method: 'form' }; } /** @@ -2607,14 +2574,15 @@ export async function filterList(text, { field, exact } = {}) { } await page.waitForTimeout(2000); - // 5. Close dialog if it stayed open (some forms keep it open after Найти) - // Check for modalSurface directly — more reliable than detectFormScript. + // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). + // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals + // (e.g. a selection form that opened this advanced search). for (let attempt = 0; attempt < 3; attempt++) { - const hasModal = await page.evaluate(`(() => { - const m = document.getElementById('modalSurface'); - return m && m.offsetWidth > 0; + const dialogVisible = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); })()`); - if (!hasModal) break; + if (!dialogVisible) break; await page.keyboard.press('Escape'); await page.waitForTimeout(500); } diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md index 11161a8f..8986d5b1 100644 --- a/docs/web-test-guide.md +++ b/docs/web-test-guide.md @@ -236,7 +236,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?)` | Выбрать из справочника. `{ type }` для полей составного типа | form state с `selected` | +| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст или `{поле: значение}`. `{ type }` для составного типа | form state с `selected` | | `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку таблицы. `add: true` = новая, `row: N` = редактирование | form state | | `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state | | `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да" | form state |