feat(web-test): support per-field search in selectValue via object syntax

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 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-03-04 20:53:11 +03:00
parent 2e0dcb773b
commit 22bf17ee00
3 changed files with 83 additions and 111 deletions
+4
View File
@@ -229,9 +229,13 @@ Method is one of: `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` |
#### `selectValue(field, search, opts?)` → form state with `selected` #### `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. 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 ```js
await selectValue('Организация', 'Конфетпром'); await selectValue('Организация', 'Конфетпром');
// result.selected = { field: 'Организация', search: 'Конфетпром', method: 'dropdown'|'form' } // 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: For **composite-type fields** (accepting multiple types), specify `type` to first select the type, then the value:
+78 -110
View File
@@ -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: * Strategy:
* 1. Find search input in selection form * - string: simple search via filterList → dblclick first row
* 2. Clipboard paste search text (trusted event, more reliable than page.fill) * - object: advanced search (Alt+F) for each field sequentially
* 3. Press Enter to apply search filter * (searches by specific column, efficient on large tables) → dblclick positioned row
* 4. Wait for grid to update, then score rows
* 5. Dblclick best match; if form persists (hit a folder), try Enter as fallback
* *
* @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 }} * @returns {{ field, ok, method }} or {{ field, error, message }}
*/ */
async function pickFromSelectionForm(selFormNum, fieldName, text, origFormNum) { async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) {
// 1. Find search input in the selection form (strict — only named search fields, // 1. Apply filters based on search type
// do NOT fall back to first input — it may be a filter field like ТипСоглашения) if (typeof search === 'string') {
const searchInputId = await page.evaluate(`(() => { // Simple text search
const p = 'form${selFormNum}_'; if (search) {
const inputs = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')].filter(el => el.offsetWidth > 0); try {
const searchInput = inputs.find(el => /поиск|search|строкапоиска|SearchString|find/i.test(el.id)); await filterList(search);
return searchInput ? searchInput.id : null; } catch (e) {
})()`) await page.keyboard.press('Escape');
await waitForStable();
// 2. Fill search field via clipboard paste (more reliable than page.fill for 1C) return { field: fieldName, error: 'filter_failed', message: e.message };
if (searchInputId && text) { }
await page.click(`[id="${searchInputId}"]`); }
await page.waitForTimeout(300); } else if (search && typeof search === 'object') {
// Select all existing text and replace with paste // Per-field advanced search (Alt+F) for each entry — searches by specific column,
await page.keyboard.press('Control+A'); // more efficient than simple search on large tables (no full-text scan across all columns).
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`); const entries = Object.entries(search);
await page.keyboard.press('Control+V'); for (const [fld, val] of entries) {
await page.waitForTimeout(500); try {
// Apply search await filterList(String(val), { field: fld });
await page.keyboard.press('Enter'); } catch (e) {
// Wait for search results: loading indicator + grid row count stabilization // Advanced search failed — fall through and try with what we have
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; }
} }
} }
// 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 rowTarget = await page.evaluate(`(() => {
const p = 'form${selFormNum}_'; const p = 'form${selFormNum}_';
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); 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; if (!body) return null;
const lines = [...body.querySelectorAll('.gridLine')]; const lines = [...body.querySelectorAll('.gridLine')];
if (!lines.length) return { rowCount: 0 }; if (!lines.length) return { rowCount: 0 };
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))}; // Prefer selected/active row (positioned by advanced search), fall back to first
const ny = s => s.replace(/ё/gi, 'е'); const sel = lines.find(l => l.classList.contains('select') || l.classList.contains('active')) || lines[0];
const r = sel.getBoundingClientRect();
// Score each row: exact cell match > row includes > partial cell match return { rowCount: lines.length, matched: true,
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,
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`); })()`);
if (rowTarget?.x && rowTarget.score > 0) { if (!rowTarget?.matched) {
// 4. Dblclick the matched row await page.keyboard.press('Escape');
await page.mouse.dblclick(rowTarget.x, rowTarget.y); 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); await waitForStable(selFormNum);
// Verify selection form closed // Still open? Close and report
const stillOpen = await page.evaluate(`(() => { const stillOpen2 = await page.evaluate(`(() => {
const p = 'form${selFormNum}_'; const p = 'form${selFormNum}_';
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
})()`); })()`);
if (stillOpen) { if (stillOpen2) {
// Dblclick may have opened a folder — try Enter to select current row await page.keyboard.press('Escape');
await page.keyboard.press('Enter'); await waitForStable();
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();
}
} }
// 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 // Check for 1C error modals after selection
await page.keyboard.press('Escape'); const err = await page.evaluate(checkErrorsScript());
await waitForStable(); if (err?.modal) {
return { field: fieldName, error: 'not_found', try {
message: 'No matches in selection form for "' + text + '"' + const btn = await page.$('a.press.pressDefault');
(rowTarget?.rowCount ? ' (' + rowTarget.rowCount + ' rows checked)' : ' (grid empty)') }; 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); await page.waitForTimeout(2000);
// 5. Close dialog if it stayed open (some forms keep it open after Найти) // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
// Check for modalSurface directly — more reliable than detectFormScript. // 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++) { for (let attempt = 0; attempt < 3; attempt++) {
const hasModal = await page.evaluate(`(() => { const dialogVisible = await page.evaluate(`(() => {
const m = document.getElementById('modalSurface'); const p = 'form${dialogForm}_';
return m && m.offsetWidth > 0; return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
})()`); })()`);
if (!hasModal) break; if (!dialogVisible) break;
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.waitForTimeout(500); await page.waitForTimeout(500);
} }
+1 -1
View File
@@ -236,7 +236,7 @@ await closeForm({ save: false });
|---------|----------|------------| |---------|----------|------------|
| `clickElement(text, {dblclick?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия из списка | form state или `{ submenu }` | | `clickElement(text, {dblclick?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия из списка | form state или `{ submenu }` |
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры) | `{ filled: [{field, ok, method}], form }` | | `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 | | `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку таблицы. `add: true` = новая, `row: N` = редактирование | form state |
| `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state | | `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state |
| `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да" | form state | | `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да" | form state |