From 3a6d5abffc7cd0b60c6d5eed43634bda2bcec8ea Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 12:28:31 +0300 Subject: [PATCH] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=20C.8=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20forms/select-value.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос selectValue + helpers из browser.mjs (~960 LOC): - scanGridRows, dblclickAndVerify, advancedSearchInline - pickFromSelectionForm, isTypeDialog, pickFromTypeDialog (экспортируются — вызываются из fillFields/fillTableRow в browser.mjs) - fillReferenceField (экспортируется — вызывается из fillFields) - selectValue Двумя слайсами вокруг fillFields/fillField/clickElement/closeForm, которые остаются в browser.mjs до этапов C.9/C.10. browser.mjs: 4095 → 2933 LOC. 56 публичных экспортов. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 945 +---------------- .../web-test/scripts/forms/select-value.mjs | 959 ++++++++++++++++++ 2 files changed, 967 insertions(+), 937 deletions(-) create mode 100644 .claude/skills/web-test/scripts/forms/select-value.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index fc4d5f78..64102952 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -757,634 +757,15 @@ export async function readSpreadsheet() { }; } -/** - * 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). - */ -async function scanGridRows(formNum, searchLower) { - return page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); - if (!grid) return null; - const body = grid.querySelector('.gridBody'); - 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; - } else { - sel = lines[0]; // empty search → first row - } - if (!sel) return null; - 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 }; - })()`); -} +// ============================================================ +// Value selection (DLB/CB) — extracted to forms/select-value.mjs +// ============================================================ +export { selectValue } from './forms/select-value.mjs'; +import { + selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, + fillReferenceField, +} from './forms/select-value.mjs'; -/** - * Select a row in a selection form via click + Enter, verify it closed. - * Uses click + Enter instead of dblclick because dblclick toggles - * expand/collapse in tree-style selection forms. - * Returns { field, ok: true, method: 'form' } on success, - * or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row). - */ -async function dblclickAndVerify(coords, selFormNum, fieldName) { - // Click to highlight the row, then Enter to confirm selection. - // This works for both flat grids and tree forms (dblclick would - // toggle expand/collapse on tree group rows). - await page.mouse.click(coords.x, coords.y); - await page.waitForTimeout(200); - await page.keyboard.press('Enter'); - 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) { - // Enter didn't select — item is likely a non-selectable group. - // Don't Escape here — let the caller decide (may want to try another row). - return { field: fieldName, ok: false, reason: 'still_open' }; - } - - // 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' }; -} - -/** - * Inline advanced search on a selection form via Alt+F. - * Does NOT click any column — FieldSelector auto-populates with main representation. - * Switches to "по части строки" (CompareType#1) to avoid composite type issues. - * Does not throw — returns silently on failure. - */ -async function advancedSearchInline(formNum, text) { - try { - // 1. Open advanced search via Alt+F - await page.keyboard.press('Alt+f'); - await page.waitForTimeout(2000); - - const dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog - - // 2. Switch to "по части строки" (CompareType#1) - const radioClicked = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - const el = document.getElementById(p + 'CompareType#1#radio'); - if (!el || el.offsetWidth === 0) return false; - if (el.classList.contains('select')) return true; // already selected - const r = el.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (radioClicked && typeof radioClicked === 'object') { - await page.mouse.click(radioClicked.x, radioClicked.y); - await page.waitForTimeout(300); - } - - // 3. Fill Pattern field via clipboard paste - const patternId = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); - return el ? el.id : null; - })()`); - if (!patternId) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - return; - } - await page.click(`[id="${patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - - // 4. Click "Найти" - const findBtn = await page.evaluate(`(() => { - const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); - const btn = btns.find(el => el.innerText?.trim() === 'Найти'); - if (!btn) return null; - const r = btn.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (findBtn) { - await page.mouse.click(findBtn.x, findBtn.y); - await page.waitForTimeout(2000); - } - - // 5. Close advanced search dialog - for (let attempt = 0; attempt < 3; attempt++) { - const dialogVisible = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (!dialogVisible) break; - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } - await waitForStable(formNum); - } catch { /* silently fail — caller will re-scan and handle not_found */ } -} - -/** - * Pick a value from an opened selection form. - * - * Strategy (escalating): - * 1. Scan visible rows for text match (exact → startsWith → includes) - * 2. Advanced search (Alt+F, "по части строки") → re-scan - * 3. Fallback: simple search (search input + Enter) → re-scan - * 4. Not found → Escape → error - * - * For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan. - * For empty search: pick first visible 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, search, origFormNum) { - const searchText = typeof search === 'string' - ? search : (search ? Object.values(search).join(' ') : ''); - const searchLower = normYo((searchText || '').toLowerCase()); - - // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group). - let hadUnselectableMatch = false; - 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) - return null; // form still open, try next step - } - - // 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; - } - } - - // Step 2: Advanced search (Alt+F — fast, no overlay issues) - if (typeof search === 'object' && search) { - // Per-field advanced search via filterList(val, {field}) - for (const [fld, val] of Object.entries(search)) { - try { await filterList(String(val), { field: fld }); } catch { /* proceed */ } - } - } else if (searchLower) { - // 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; - } - } - - // Step 3: Fallback — simple search via search input (for forms without Alt+F support) - if (typeof search === 'string' && searchLower) { - const searchInputId = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? el.id : null; - })()`); - if (searchInputId) { - try { - await page.click(`[id="${searchInputId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(searchText); - await page.waitForTimeout(300); - 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; - } - } - } - - // 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; - } - } - - await page.keyboard.press('Escape'); - await waitForStable(); - const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); - if (hadUnselectableMatch) { - return { field: fieldName, error: 'not_selectable', - message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' }; - } - return { field: fieldName, error: 'not_found', - message: 'No matches in selection form for ' + searchDesc }; -} - -/** - * Detect whether a form is a type selection dialog ("Выбор типа данных"). - * Type dialogs appear when selecting a value for a composite-type field. - * - * Detection signals (any one is sufficient): - * - form{N}_OK element exists (selection forms use "Выбрать", not "OK") - * - form{N}_ValueList grid exists (specific to type/value list dialogs) - * - Window title contains "Выбор типа" (title attr on .toplineBoxTitle) - */ -async function isTypeDialog(formNum) { - return page.evaluate(`(() => { - const p = 'form' + ${formNum} + '_'; - const hasOK = !!document.getElementById(p + 'OK'); - const hasValueList = !!document.getElementById(p + 'ValueList'); - const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')] - .some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || '')); - return hasOK || hasValueList || hasTitle; - })()`); -} - -/** - * Select a type from the type selection dialog ("Выбор типа данных") - * using Ctrl+F search. The dialog has a virtual grid (~5 visible rows), - * so Ctrl+F is the only reliable way to find a type. - * - * Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) → - * verify selected row matches → Enter (OK) - * - * @param {number} formNum - type dialog form number - * @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт") - * @throws {Error} if type not found - */ -async function pickFromTypeDialog(formNum, typeName) { - // The type dialog is a modal ValueList grid. - // Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists. - // - // Key constraints discovered during testing: - // - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true}) - // which punches through the modal overlay to the form underneath - // - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog) - // - Buttons: use page.click({force:true}), NOT evaluate(() => el.click()) - // because evaluate click doesn't trigger 1C's event chain properly - // - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти" - // - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open - - const typeNorm = normYo(typeName.toLowerCase()); - - // Helper: read visible rows and find matching ones - async function readVisibleRows() { - return page.evaluate(`(() => { - const grid = document.getElementById('form${formNum}_ValueList'); - if (!grid) return { visible: [], matches: [] }; - const body = grid.querySelector('.gridBody'); - if (!body) return { visible: [], matches: [] }; - const lines = body.querySelectorAll('.gridLine'); - const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim(); - const typeNorm = ${JSON.stringify(typeNorm)}; - const visible = []; - const matches = []; - for (const line of lines) { - const text = norm(line.innerText); - if (!text) continue; - visible.push(text); - if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) { - const r = line.getBoundingClientRect(); - matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - } - } - return { visible, matches }; - })()`); - } - - // Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists) - const scan = await readVisibleRows(); - - if (scan.matches.length === 1) { - // Single match — click to select, then OK - await page.mouse.click(scan.matches[0].x, scan.matches[0].y); - await page.waitForTimeout(200); - await page.click(`#form${formNum}_OK`, { force: true }); - await page.waitForTimeout(ACTION_WAIT); - return; - } - - if (scan.matches.length > 1) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } - await waitForStable(); - throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); - } - - // Step 2: Not found in visible rows — use Ctrl+F (virtual grid may have more items) - - // Focus the grid via evaluate (does NOT punch through modal like page.click) - await page.evaluate(`(() => { - const grid = document.getElementById('form${formNum}_ValueList'); - if (!grid) return; - const body = grid.querySelector('.gridBody'); - if (body) body.focus(); else grid.focus(); - })()`); - await page.waitForTimeout(300); - - // Ctrl+F to open "Найти" dialog - await page.keyboard.press('Control+f'); - await page.waitForTimeout(1000); - - // Paste search text (focus is on "Что искать" field) - await page.keyboard.press('Control+a'); - await pasteText(typeName); - await page.waitForTimeout(300); - - // Find the "Найти" dialog form number (it's > formNum) - const findFormNum = await page.evaluate(`(() => { - for (let n = ${formNum} + 1; n < ${formNum} + 20; n++) { - const btn = document.getElementById('form' + n + '_Find'); - if (btn && btn.offsetWidth > 0) return n; - } - return null; - })()`); - - if (findFormNum === null) { - await page.keyboard.press('Escape'); - await waitForStable(); - throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection'); - } - - // Click "Найти" — search is client-side (no server round-trip), 500ms is enough - await page.click(`#form${findFormNum}_Find`, { force: true }); - await page.waitForTimeout(500); - - // Re-read visible rows after search scrolled to match - const afterSearch = await readVisibleRows(); - - if (afterSearch.matches.length === 0) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } - await waitForStable(); - throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` + - `. Visible: ${(scan.visible || []).join(', ')}`); - } - - if (afterSearch.matches.length > 1) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } - await waitForStable(); - throw new Error(`selectValue: multiple types match "${typeName}": ${afterSearch.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); - } - - // Click OK on type dialog via page.click({force:true}) — bypasses "Найти" modal - await page.click(`#form${formNum}_OK`, { force: true }); - await page.waitForTimeout(ACTION_WAIT); -} - -/** - * Fill a reference field via clipboard paste + 1C autocomplete. - * - * Strategy: - * 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors) - * 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete) - * 3. Check editDropDown for autocomplete results → click match or Tab to resolve - * 4. Verify result: resolved → ok, not found → clear + error - * - * Clipboard paste was chosen because: - * - Ctrl+V produces trusted browser events that 1C respects for autocomplete - * - page.fill() + synthetic keydown/keyup only triggers hints, not real search - * - keyboard.type() garbles Cyrillic on some fields - * - * @returns {{ field, ok?, method?, error?, value?, message?, available? }} - */ -async function fillReferenceField(selector, fieldName, value, formNum) { - const text = String(value); - const escapedSel = selector.replace(/'/g, "\\'"); - - // Helper: detect new forms opened above the current one (strict — interactive - // elements only; fillReferenceField-specific) - const detectNewForm = () => helperDetectNewForm(formNum, { strict: true }); - - // Helper: clear the field using Shift+F4 (native 1C mechanism) - async function clearField() { - try { - await page.click(selector, { timeout: 3000 }); - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - } catch { /* OK */ } - } - - // Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке") - async function checkNotInListCloud() { - return page.evaluate(`(() => { - const divs = document.querySelectorAll('div'); - for (const el of divs) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const style = getComputedStyle(el); - if (style.position !== 'absolute' && style.position !== 'fixed') continue; - const z = parseInt(style.zIndex) || 0; - if (z < 100) continue; - if ((el.innerText || '').includes('нет в списке')) return true; - } - return false; - })()`); - } - - // 0. Dismiss any leftover error modal from a previous operation - await dismissPendingErrors(); - - // 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields - // and also for reference fields that show a dropdown. - const inputId = selector.match(/\[id="(.+)"\]/)?.[1]; - // DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB) - const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB'; - const dlbSelector = `[id="${dlbId}"]`; - try { - const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`); - if (dlbVisible) { - await page.click(dlbSelector); - await page.waitForTimeout(1000); - const eddState = await readEdd(); - if (eddState.visible && eddState.items?.length > 0) { - const target = normYo(text.toLowerCase()); - const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); - let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target); - if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (!match) match = candidates.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name.includes(target) || target.includes(name); - }); - if (match) { - await page.mouse.click(match.x, match.y); - await waitForStable(); - await dismissPendingErrors(); - return { field: fieldName, ok: true, method: 'dropdown', - value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; - } - // No match in DLB dropdown — close and fall through to paste approach - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - } else if (eddState.visible) { - // DLB opened a hint popup (no .eddText items) — close it before proceeding - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - } - } - } catch { /* DLB approach failed — fall through to paste */ } - - // 1. Focus (handle surface/modal overlay from previous interaction) - await safeClick(selector, { dismissErrors: true }); - - // 2. If field already has a value, clear using Shift+F4 (native 1C mechanism). - // This is needed for reference fields — Shift+F4 properly clears the ref link. - const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); - if (currentVal) { - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(500); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - // Refocus - await page.click(selector); - } - - // 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete) - await pasteText(text); - await page.waitForTimeout(2000); - - // 4. Check editDropDown for autocomplete suggestions - const eddState = await readEdd(); - - if (eddState.visible && eddState.items?.length > 0) { - const target = normYo(text.toLowerCase()); - // Separate real matches from "Создать:" items - const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); - - if (candidates.length > 0) { - // Find best match (items have format "Name (Code)" — match against name part) - let match = candidates.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name === target; - }); - if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (!match) match = candidates.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name.includes(target) || target.includes(name); - }); - - if (match) { - await page.mouse.click(match.x, match.y); - await waitForStable(); - await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async - return { field: fieldName, ok: true, method: 'dropdown', - value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; - } - // Candidates exist but none match — report them - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_matched', - available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; - } - - // Only "Создать:" items — no existing matches - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'No existing values match "' + text + '"' }; - } - - // 4b. No edd — check for "not in list" cloud that may have appeared during paste - if (await checkNotInListCloud()) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found (not in list)' }; - } - - // 5. No edd at all — press Tab to trigger direct resolve - await page.keyboard.press('Tab'); - await waitForStable(); - await dismissPendingErrors(); - - // 5x. Check for "not in list" cloud popup after Tab - if (await checkNotInListCloud()) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found (not in list)' }; - } - - // 5a. New form opened? (creation form = value not found) - const newForm = await detectNewForm(); - if (newForm !== null) { - await page.keyboard.press('Escape'); - await waitForStable(); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found' }; - } - - // 5b. Dropdown after Tab? - const popup = await page.evaluate(readSubmenuScript()); - if (Array.isArray(popup) && popup.length > 0) { - const realItems = popup.filter(i => !i.name.startsWith('Создать')); - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - if (realItems.length > 0) { - return { field: fieldName, error: 'ambiguous', - message: 'Multiple matches for "' + text + '"', - available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; - } - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found' }; - } - - // 5c. Check final value - const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); - if (!finalVal) { - // 6. Last resort: try F4 to open selection form and pick from there - try { - await page.click(selector); - await page.waitForTimeout(300); - } catch { /* OK — field may be unfocused */ } - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - - const selFormNum = await detectNewForm(); - if (selFormNum !== null) { - const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum); - if (pickResult.ok) return pickResult; - // pickFromSelectionForm already closed the form on error - } - - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found (field is empty)' }; - } - - return { field: fieldName, ok: true, method: 'typeahead', value: finalVal }; -} /** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ @@ -1871,316 +1252,6 @@ export async function closeForm({ save } = {}) { return state; } -/** - * Select a value from a reference field (compound operation). - * Handles three patterns: - * A) DLB opens an inline dropdown popup — click matching item - * B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form - * C) DLB opens a separate selection form directly — search + dblclick in grid - */ -export async function selectValue(fieldName, searchText, { type } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error(`selectValue: no form found`); - - // Detect any new form opened above this one (broad — includes type dialogs). - // Hoisted to the top so the composite-type branch can call it before its - // original declaration site further below. - const detectNewForm = () => helperDetectNewForm(formNum); - - // 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields) - let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB')); - if (btn?.error === 'button_not_found') { - btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB')); - } - if (btn?.error) return btn; - if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {} - try { - - // === CLEAR FIELD if searchText is empty/null === - if (!searchText && searchText !== 0) { - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (inputId) { - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await waitForStable(); - } - if (highlightMode) try { await unhighlight(); } catch {} - const formData = await getFormState(); - return { ...formData, selected: { field: fieldName, search: null, method: 'clear' } }; - } - - // === COMPOSITE TYPE HANDLING === - // When `type` is specified, clear the field first to reset cached type, - // then open type selection dialog, pick the type, then pick the value. - if (type) { - // Find and focus the field input - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`); - - // Clear cached type + value with Shift+F4 - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(500); - - // Re-focus and press F4 to open type selection dialog - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - await waitForStable(formNum); - - const newFormNum = await detectNewForm(); - if (newFormNum === null) { - throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`); - } - - if (await isTypeDialog(newFormNum)) { - // Pick type from the dialog - await pickFromTypeDialog(newFormNum, type); - await waitForStable(newFormNum); - - // After type selection, the actual selection form should open - const selFormNum = await detectSelectionForm(); - if (selFormNum === null) { - throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`); - } - - const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } else { - // Not a type dialog — field is not composite type, proceed with normal selection - const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - } - // === END COMPOSITE TYPE HANDLING === - - // Auto-enable DCS checkbox if resolved via label - if (btn.dcsCheckbox) { - const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`; - const isChecked = await page.$eval(cbSel, el => - el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select')); - if (!isChecked) { await page.click(cbSel); await waitForStable(); } - } - - // Helper: detect selection form (form number > formNum) - async function detectSelectionForm() { - return page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 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; - })()`); - } - - // detectNewForm is hoisted at the top of selectValue (see above). - - // Helper: open selection form and pick value - async function openFormAndPick() { - await waitForStable(formNum); - const selFormNum = await detectSelectionForm(); - if (selFormNum !== null) { - const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - return null; - } - - // Helper: click EDD item via evaluate (bypasses div.surface overlay from DLB) - // page.mouse.click() doesn't work here — surface intercepts pointer events. - // Dispatching mousedown directly on the element avoids this. - async function clickEddItem(itemName) { - return page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return null; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(itemName.toLowerCase())}); - const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); - function clickEl(el) { - const r = el.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return el.innerText.trim(); - } - // Pass 1: exact match (prefer over partial) - for (const el of items) { - const t = ny((el.innerText?.trim() || '').toLowerCase()); - if (t === target) return clickEl(el); - const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, ''); - if (stripped === target) return clickEl(el); - } - // Pass 2: partial match - for (const el of items) { - const t = ny((el.innerText?.trim() || '').toLowerCase()); - if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el); - } - return null; - })()`); - } - - // Helper: click "Показать все" in EDD footer via evaluate - async function clickShowAll() { - return page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return false; - let el = edd.querySelector('.eddBottom .hyperlink'); - if (!el || el.offsetWidth === 0) { - const candidates = [...edd.querySelectorAll('span, div, a')] - .filter(e => e.offsetWidth > 0 && e.children.length === 0); - el = candidates.find(e => { - const t = (e.innerText?.trim() || '').toLowerCase(); - return t === 'показать все' || t === 'show all'; - }); - } - if (!el) return false; - const r = el.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return true; - })()`); - } - - // 2. Click DLB (handle funcPanel / surface overlay intercept) - const dlbSel = `[id="${btn.buttonId}"]`; - await safeClick(dlbSel, { timeout: 5000 }); - await page.waitForTimeout(ACTION_WAIT); - - // 3A. Check if a dropdown popup appeared (inline quick selection) - const popupItems = await page.evaluate(readSubmenuScript()); - if (Array.isArray(popupItems) && popupItems.length > 0) { - const regularItems = popupItems.filter(i => i.kind !== 'showAll'); - const showAllItem = popupItems.find(i => i.kind === 'showAll'); - - if (searchText) { - const target = normYo(searchText.toLowerCase()); - // Try to find match among regular dropdown items - let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target); - if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (!match) match = regularItems.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name === target || name.includes(target) || target.includes(name); - }); - - if (match) { - // Click via evaluate to bypass div.surface overlay - await clickEddItem(match.name); - await waitForStable(); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText, method: 'dropdown' }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - - // No match in dropdown — try "Показать все" to open selection form - if (showAllItem) { - await clickShowAll(); - const formResult = await openFormAndPick(); - if (formResult) return formResult; - } - - // No "Показать все" — close dropdown, try F4 - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - - // Focus the field input and press F4 to open selection form - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (inputId) { - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - } - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - - const formResult = await openFormAndPick(); - if (formResult) return formResult; - - // Still nothing — report available items from original dropdown - throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`); - } - - // No search text — click first regular item - if (regularItems.length > 0) { - await clickEddItem(regularItems[0].name); - await waitForStable(); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - } - - // 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs) - const selFormNum = await detectNewForm(); - if (selFormNum !== null) { - // Auto-detect type selection dialog when `type` was not specified - if (await isTypeDialog(selFormNum)) { - await page.keyboard.press('Escape'); - await waitForStable(); - throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`); - } - const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - - // 3C. Neither popup nor form — try F4 as last resort - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (inputId) { - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - } - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - - const formResult = await openFormAndPick(); - if (formResult) return formResult; - - throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`); - - } finally { if (highlightMode) try { await unhighlight(); } catch {} } -} /** * Fill cells in the current table row via Tab navigation. diff --git a/.claude/skills/web-test/scripts/forms/select-value.mjs b/.claude/skills/web-test/scripts/forms/select-value.mjs new file mode 100644 index 00000000..527dafaa --- /dev/null +++ b/.claude/skills/web-test/scripts/forms/select-value.mjs @@ -0,0 +1,959 @@ +// web-test forms/select-value v1.16 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, ensureConnected, normYo, highlightMode, ACTION_WAIT, +} from '../core/state.mjs'; +import { + detectFormScript, findFieldButtonScript, resolveFieldsScript, + readSubmenuScript, checkErrorsScript, +} from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; +import { waitForStable, waitForCondition } from '../core/wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { + safeClick, findFieldInputId, readEdd, + detectNewForm as helperDetectNewForm, +} from '../core/helpers.mjs'; +// pasteText + getFormState live in browser.mjs. +import { pasteText, getFormState } from '../browser.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). + */ +async function scanGridRows(formNum, searchLower) { + return page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + 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; + } else { + sel = lines[0]; // empty search → first row + } + if (!sel) return null; + 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 }; + })()`); +} + +/** + * Select a row in a selection form via click + Enter, verify it closed. + * Uses click + Enter instead of dblclick because dblclick toggles + * expand/collapse in tree-style selection forms. + * Returns { field, ok: true, method: 'form' } on success, + * or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row). + */ +async function dblclickAndVerify(coords, selFormNum, fieldName) { + // Click to highlight the row, then Enter to confirm selection. + // This works for both flat grids and tree forms (dblclick would + // toggle expand/collapse on tree group rows). + await page.mouse.click(coords.x, coords.y); + await page.waitForTimeout(200); + await page.keyboard.press('Enter'); + 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) { + // Enter didn't select — item is likely a non-selectable group. + // Don't Escape here — let the caller decide (may want to try another row). + return { field: fieldName, ok: false, reason: 'still_open' }; + } + + // 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' }; +} + +/** + * Inline advanced search on a selection form via Alt+F. + * Does NOT click any column — FieldSelector auto-populates with main representation. + * Switches to "по части строки" (CompareType#1) to avoid composite type issues. + * Does not throw — returns silently on failure. + */ +async function advancedSearchInline(formNum, text) { + try { + // 1. Open advanced search via Alt+F + await page.keyboard.press('Alt+f'); + await page.waitForTimeout(2000); + + const dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog + + // 2. Switch to "по части строки" (CompareType#1) + const radioClicked = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = document.getElementById(p + 'CompareType#1#radio'); + if (!el || el.offsetWidth === 0) return false; + if (el.classList.contains('select')) return true; // already selected + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (radioClicked && typeof radioClicked === 'object') { + await page.mouse.click(radioClicked.x, radioClicked.y); + await page.waitForTimeout(300); + } + + // 3. Fill Pattern field via clipboard paste + const patternId = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + return el ? el.id : null; + })()`); + if (!patternId) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return; + } + await page.click(`[id="${patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + + // 4. Click "Найти" + const findBtn = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); + const btn = btns.find(el => el.innerText?.trim() === 'Найти'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (findBtn) { + await page.mouse.click(findBtn.x, findBtn.y); + await page.waitForTimeout(2000); + } + + // 5. Close advanced search dialog + for (let attempt = 0; attempt < 3; attempt++) { + const dialogVisible = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (!dialogVisible) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await waitForStable(formNum); + } catch { /* silently fail — caller will re-scan and handle not_found */ } +} + +/** + * Pick a value from an opened selection form. + * + * Strategy (escalating): + * 1. Scan visible rows for text match (exact → startsWith → includes) + * 2. Advanced search (Alt+F, "по части строки") → re-scan + * 3. Fallback: simple search (search input + Enter) → re-scan + * 4. Not found → Escape → error + * + * For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan. + * For empty search: pick first visible 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 }} + */ +export async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { + const searchText = typeof search === 'string' + ? search : (search ? Object.values(search).join(' ') : ''); + const searchLower = normYo((searchText || '').toLowerCase()); + + // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group). + let hadUnselectableMatch = false; + 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) + return null; // form still open, try next step + } + + // 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; + } + } + + // Step 2: Advanced search (Alt+F — fast, no overlay issues) + if (typeof search === 'object' && search) { + // Per-field advanced search via filterList(val, {field}) + for (const [fld, val] of Object.entries(search)) { + try { await filterList(String(val), { field: fld }); } catch { /* proceed */ } + } + } else if (searchLower) { + // 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; + } + } + + // Step 3: Fallback — simple search via search input (for forms without Alt+F support) + if (typeof search === 'string' && searchLower) { + const searchInputId = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? el.id : null; + })()`); + if (searchInputId) { + try { + await page.click(`[id="${searchInputId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(searchText); + await page.waitForTimeout(300); + 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; + } + } + } + + // 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; + } + } + + await page.keyboard.press('Escape'); + await waitForStable(); + const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); + if (hadUnselectableMatch) { + return { field: fieldName, error: 'not_selectable', + message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' }; + } + return { field: fieldName, error: 'not_found', + message: 'No matches in selection form for ' + searchDesc }; +} + +/** + * Detect whether a form is a type selection dialog ("Выбор типа данных"). + * Type dialogs appear when selecting a value for a composite-type field. + * + * Detection signals (any one is sufficient): + * - form{N}_OK element exists (selection forms use "Выбрать", not "OK") + * - form{N}_ValueList grid exists (specific to type/value list dialogs) + * - Window title contains "Выбор типа" (title attr on .toplineBoxTitle) + */ +export async function isTypeDialog(formNum) { + return page.evaluate(`(() => { + const p = 'form' + ${formNum} + '_'; + const hasOK = !!document.getElementById(p + 'OK'); + const hasValueList = !!document.getElementById(p + 'ValueList'); + const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')] + .some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || '')); + return hasOK || hasValueList || hasTitle; + })()`); +} + +/** + * Select a type from the type selection dialog ("Выбор типа данных") + * using Ctrl+F search. The dialog has a virtual grid (~5 visible rows), + * so Ctrl+F is the only reliable way to find a type. + * + * Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) → + * verify selected row matches → Enter (OK) + * + * @param {number} formNum - type dialog form number + * @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт") + * @throws {Error} if type not found + */ +export async function pickFromTypeDialog(formNum, typeName) { + // The type dialog is a modal ValueList grid. + // Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists. + // + // Key constraints discovered during testing: + // - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true}) + // which punches through the modal overlay to the form underneath + // - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog) + // - Buttons: use page.click({force:true}), NOT evaluate(() => el.click()) + // because evaluate click doesn't trigger 1C's event chain properly + // - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти" + // - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open + + const typeNorm = normYo(typeName.toLowerCase()); + + // Helper: read visible rows and find matching ones + async function readVisibleRows() { + return page.evaluate(`(() => { + const grid = document.getElementById('form${formNum}_ValueList'); + if (!grid) return { visible: [], matches: [] }; + const body = grid.querySelector('.gridBody'); + if (!body) return { visible: [], matches: [] }; + const lines = body.querySelectorAll('.gridLine'); + const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim(); + const typeNorm = ${JSON.stringify(typeNorm)}; + const visible = []; + const matches = []; + for (const line of lines) { + const text = norm(line.innerText); + if (!text) continue; + visible.push(text); + if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) { + const r = line.getBoundingClientRect(); + matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + } + } + return { visible, matches }; + })()`); + } + + // Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists) + const scan = await readVisibleRows(); + + if (scan.matches.length === 1) { + // Single match — click to select, then OK + await page.mouse.click(scan.matches[0].x, scan.matches[0].y); + await page.waitForTimeout(200); + await page.click(`#form${formNum}_OK`, { force: true }); + await page.waitForTimeout(ACTION_WAIT); + return; + } + + if (scan.matches.length > 1) { + for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await waitForStable(); + throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); + } + + // Step 2: Not found in visible rows — use Ctrl+F (virtual grid may have more items) + + // Focus the grid via evaluate (does NOT punch through modal like page.click) + await page.evaluate(`(() => { + const grid = document.getElementById('form${formNum}_ValueList'); + if (!grid) return; + const body = grid.querySelector('.gridBody'); + if (body) body.focus(); else grid.focus(); + })()`); + await page.waitForTimeout(300); + + // Ctrl+F to open "Найти" dialog + await page.keyboard.press('Control+f'); + await page.waitForTimeout(1000); + + // Paste search text (focus is on "Что искать" field) + await page.keyboard.press('Control+a'); + await pasteText(typeName); + await page.waitForTimeout(300); + + // Find the "Найти" dialog form number (it's > formNum) + const findFormNum = await page.evaluate(`(() => { + for (let n = ${formNum} + 1; n < ${formNum} + 20; n++) { + const btn = document.getElementById('form' + n + '_Find'); + if (btn && btn.offsetWidth > 0) return n; + } + return null; + })()`); + + if (findFormNum === null) { + await page.keyboard.press('Escape'); + await waitForStable(); + throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection'); + } + + // Click "Найти" — search is client-side (no server round-trip), 500ms is enough + await page.click(`#form${findFormNum}_Find`, { force: true }); + await page.waitForTimeout(500); + + // Re-read visible rows after search scrolled to match + const afterSearch = await readVisibleRows(); + + if (afterSearch.matches.length === 0) { + for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await waitForStable(); + throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` + + `. Visible: ${(scan.visible || []).join(', ')}`); + } + + if (afterSearch.matches.length > 1) { + for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await waitForStable(); + throw new Error(`selectValue: multiple types match "${typeName}": ${afterSearch.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); + } + + // Click OK on type dialog via page.click({force:true}) — bypasses "Найти" modal + await page.click(`#form${formNum}_OK`, { force: true }); + await page.waitForTimeout(ACTION_WAIT); +} + +/** + * Fill a reference field via clipboard paste + 1C autocomplete. + * + * Strategy: + * 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors) + * 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete) + * 3. Check editDropDown for autocomplete results → click match or Tab to resolve + * 4. Verify result: resolved → ok, not found → clear + error + * + * Clipboard paste was chosen because: + * - Ctrl+V produces trusted browser events that 1C respects for autocomplete + * - page.fill() + synthetic keydown/keyup only triggers hints, not real search + * - keyboard.type() garbles Cyrillic on some fields + * + * @returns {{ field, ok?, method?, error?, value?, message?, available? }} + */ +export async function fillReferenceField(selector, fieldName, value, formNum) { + const text = String(value); + const escapedSel = selector.replace(/'/g, "\\'"); + + // Helper: detect new forms opened above the current one (strict — interactive + // elements only; fillReferenceField-specific) + const detectNewForm = () => helperDetectNewForm(formNum, { strict: true }); + + // Helper: clear the field using Shift+F4 (native 1C mechanism) + async function clearField() { + try { + await page.click(selector, { timeout: 3000 }); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + } catch { /* OK */ } + } + + // Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке") + async function checkNotInListCloud() { + return page.evaluate(`(() => { + const divs = document.querySelectorAll('div'); + for (const el of divs) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const style = getComputedStyle(el); + if (style.position !== 'absolute' && style.position !== 'fixed') continue; + const z = parseInt(style.zIndex) || 0; + if (z < 100) continue; + if ((el.innerText || '').includes('нет в списке')) return true; + } + return false; + })()`); + } + + // 0. Dismiss any leftover error modal from a previous operation + await dismissPendingErrors(); + + // 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields + // and also for reference fields that show a dropdown. + const inputId = selector.match(/\[id="(.+)"\]/)?.[1]; + // DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB) + const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB'; + const dlbSelector = `[id="${dlbId}"]`; + try { + const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`); + if (dlbVisible) { + await page.click(dlbSelector); + await page.waitForTimeout(1000); + const eddState = await readEdd(); + if (eddState.visible && eddState.items?.length > 0) { + const target = normYo(text.toLowerCase()); + const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); + let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target); + if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (!match) match = candidates.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name.includes(target) || target.includes(name); + }); + if (match) { + await page.mouse.click(match.x, match.y); + await waitForStable(); + await dismissPendingErrors(); + return { field: fieldName, ok: true, method: 'dropdown', + value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; + } + // No match in DLB dropdown — close and fall through to paste approach + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } else if (eddState.visible) { + // DLB opened a hint popup (no .eddText items) — close it before proceeding + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } + } + } catch { /* DLB approach failed — fall through to paste */ } + + // 1. Focus (handle surface/modal overlay from previous interaction) + await safeClick(selector, { dismissErrors: true }); + + // 2. If field already has a value, clear using Shift+F4 (native 1C mechanism). + // This is needed for reference fields — Shift+F4 properly clears the ref link. + const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); + if (currentVal) { + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(500); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + // Refocus + await page.click(selector); + } + + // 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete) + await pasteText(text); + await page.waitForTimeout(2000); + + // 4. Check editDropDown for autocomplete suggestions + const eddState = await readEdd(); + + if (eddState.visible && eddState.items?.length > 0) { + const target = normYo(text.toLowerCase()); + // Separate real matches from "Создать:" items + const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); + + if (candidates.length > 0) { + // Find best match (items have format "Name (Code)" — match against name part) + let match = candidates.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name === target; + }); + if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (!match) match = candidates.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name.includes(target) || target.includes(name); + }); + + if (match) { + await page.mouse.click(match.x, match.y); + await waitForStable(); + await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async + return { field: fieldName, ok: true, method: 'dropdown', + value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; + } + // Candidates exist but none match — report them + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_matched', + available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; + } + + // Only "Создать:" items — no existing matches + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'No existing values match "' + text + '"' }; + } + + // 4b. No edd — check for "not in list" cloud that may have appeared during paste + if (await checkNotInListCloud()) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (not in list)' }; + } + + // 5. No edd at all — press Tab to trigger direct resolve + await page.keyboard.press('Tab'); + await waitForStable(); + await dismissPendingErrors(); + + // 5x. Check for "not in list" cloud popup after Tab + if (await checkNotInListCloud()) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (not in list)' }; + } + + // 5a. New form opened? (creation form = value not found) + const newForm = await detectNewForm(); + if (newForm !== null) { + await page.keyboard.press('Escape'); + await waitForStable(); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found' }; + } + + // 5b. Dropdown after Tab? + const popup = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popup) && popup.length > 0) { + const realItems = popup.filter(i => !i.name.startsWith('Создать')); + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + if (realItems.length > 0) { + return { field: fieldName, error: 'ambiguous', + message: 'Multiple matches for "' + text + '"', + available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; + } + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found' }; + } + + // 5c. Check final value + const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); + if (!finalVal) { + // 6. Last resort: try F4 to open selection form and pick from there + try { + await page.click(selector); + await page.waitForTimeout(300); + } catch { /* OK — field may be unfocused */ } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const selFormNum = await detectNewForm(); + if (selFormNum !== null) { + const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum); + if (pickResult.ok) return pickResult; + // pickFromSelectionForm already closed the form on error + } + + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (field is empty)' }; + } + + return { field: fieldName, ok: true, method: 'typeahead', value: finalVal }; +} + +/** + * Select a value from a reference field (compound operation). + * Handles three patterns: + * A) DLB opens an inline dropdown popup — click matching item + * B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form + * C) DLB opens a separate selection form directly — search + dblclick in grid + */ +export async function selectValue(fieldName, searchText, { type } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error(`selectValue: no form found`); + + // Detect any new form opened above this one (broad — includes type dialogs). + // Hoisted to the top so the composite-type branch can call it before its + // original declaration site further below. + const detectNewForm = () => helperDetectNewForm(formNum); + + // 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields) + let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB')); + if (btn?.error === 'button_not_found') { + btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB')); + } + if (btn?.error) return btn; + if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {} + try { + + // === CLEAR FIELD if searchText is empty/null === + if (!searchText && searchText !== 0) { + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await waitForStable(); + } + if (highlightMode) try { await unhighlight(); } catch {} + const formData = await getFormState(); + return { ...formData, selected: { field: fieldName, search: null, method: 'clear' } }; + } + + // === COMPOSITE TYPE HANDLING === + // When `type` is specified, clear the field first to reset cached type, + // then open type selection dialog, pick the type, then pick the value. + if (type) { + // Find and focus the field input + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`); + + // Clear cached type + value with Shift+F4 + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(500); + + // Re-focus and press F4 to open type selection dialog + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + await waitForStable(formNum); + + const newFormNum = await detectNewForm(); + if (newFormNum === null) { + throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`); + } + + if (await isTypeDialog(newFormNum)) { + // Pick type from the dialog + await pickFromTypeDialog(newFormNum, type); + await waitForStable(newFormNum); + + // After type selection, the actual selection form should open + const selFormNum = await detectSelectionForm(); + if (selFormNum === null) { + throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`); + } + + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } else { + // Not a type dialog — field is not composite type, proceed with normal selection + const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + } + // === END COMPOSITE TYPE HANDLING === + + // Auto-enable DCS checkbox if resolved via label + if (btn.dcsCheckbox) { + const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`; + const isChecked = await page.$eval(cbSel, el => + el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select')); + if (!isChecked) { await page.click(cbSel); await waitForStable(); } + } + + // Helper: detect selection form (form number > formNum) + async function detectSelectionForm() { + return page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { + if (el.offsetWidth === 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; + })()`); + } + + // detectNewForm is hoisted at the top of selectValue (see above). + + // Helper: open selection form and pick value + async function openFormAndPick() { + await waitForStable(formNum); + const selFormNum = await detectSelectionForm(); + if (selFormNum !== null) { + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + return null; + } + + // Helper: click EDD item via evaluate (bypasses div.surface overlay from DLB) + // page.mouse.click() doesn't work here — surface intercepts pointer events. + // Dispatching mousedown directly on the element avoids this. + async function clickEddItem(itemName) { + return page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return null; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(itemName.toLowerCase())}); + const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); + function clickEl(el) { + const r = el.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return el.innerText.trim(); + } + // Pass 1: exact match (prefer over partial) + for (const el of items) { + const t = ny((el.innerText?.trim() || '').toLowerCase()); + if (t === target) return clickEl(el); + const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, ''); + if (stripped === target) return clickEl(el); + } + // Pass 2: partial match + for (const el of items) { + const t = ny((el.innerText?.trim() || '').toLowerCase()); + if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el); + } + return null; + })()`); + } + + // Helper: click "Показать все" in EDD footer via evaluate + async function clickShowAll() { + return page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return false; + let el = edd.querySelector('.eddBottom .hyperlink'); + if (!el || el.offsetWidth === 0) { + const candidates = [...edd.querySelectorAll('span, div, a')] + .filter(e => e.offsetWidth > 0 && e.children.length === 0); + el = candidates.find(e => { + const t = (e.innerText?.trim() || '').toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + } + if (!el) return false; + const r = el.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return true; + })()`); + } + + // 2. Click DLB (handle funcPanel / surface overlay intercept) + const dlbSel = `[id="${btn.buttonId}"]`; + await safeClick(dlbSel, { timeout: 5000 }); + await page.waitForTimeout(ACTION_WAIT); + + // 3A. Check if a dropdown popup appeared (inline quick selection) + const popupItems = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popupItems) && popupItems.length > 0) { + const regularItems = popupItems.filter(i => i.kind !== 'showAll'); + const showAllItem = popupItems.find(i => i.kind === 'showAll'); + + if (searchText) { + const target = normYo(searchText.toLowerCase()); + // Try to find match among regular dropdown items + let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target); + if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (!match) match = regularItems.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name === target || name.includes(target) || target.includes(name); + }); + + if (match) { + // Click via evaluate to bypass div.surface overlay + await clickEddItem(match.name); + await waitForStable(); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText, method: 'dropdown' }; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + + // No match in dropdown — try "Показать все" to open selection form + if (showAllItem) { + await clickShowAll(); + const formResult = await openFormAndPick(); + if (formResult) return formResult; + } + + // No "Показать все" — close dropdown, try F4 + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + + // Focus the field input and press F4 to open selection form + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const formResult = await openFormAndPick(); + if (formResult) return formResult; + + // Still nothing — report available items from original dropdown + throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`); + } + + // No search text — click first regular item + if (regularItems.length > 0) { + await clickEddItem(regularItems[0].name); + await waitForStable(); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' }; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + } + + // 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs) + const selFormNum = await detectNewForm(); + if (selFormNum !== null) { + // Auto-detect type selection dialog when `type` was not specified + if (await isTypeDialog(selFormNum)) { + await page.keyboard.press('Escape'); + await waitForStable(); + throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`); + } + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + + // 3C. Neither popup nor form — try F4 as last resort + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const formResult = await openFormAndPick(); + if (formResult) return formResult; + + throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`); + + } finally { if (highlightMode) try { await unhighlight(); } catch {} } +}