mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
feat(web-test): add table parameter for multi-grid forms
Add semantic table binding to readTable, clickElement, fillTableRow, and deleteTableRow — resolves the correct grid by name when a form has multiple tables (e.g. "Входящие"/"Исходящие" in BP links). - New resolveGridScript() in dom.mjs: cascade match by gridName → columns - findClickTargetScript: scoped button search within grid's parent container - getFormState: reports all grids via tables[] array (table still present for compat) - All grids[grids.length-1] fallbacks wrapped in gridSelector ternary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
findClickTargetScript, findFieldButtonScript, readSubmenuScript,
|
||||
resolveFieldsScript, getFormStateScript,
|
||||
detectFormScript, readTableScript, checkErrorsScript,
|
||||
switchTabScript
|
||||
switchTabScript, resolveGridScript
|
||||
} from './dom.mjs';
|
||||
|
||||
let browser = null;
|
||||
@@ -548,11 +548,17 @@ export async function getFormState() {
|
||||
}
|
||||
|
||||
/** Read structured table data with pagination. Returns columns, rows, total count. */
|
||||
export async function readTable({ maxRows = 20, offset = 0 } = {}) {
|
||||
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
|
||||
ensureConnected();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('readTable: no form found');
|
||||
return await page.evaluate(readTableScript(formNum, { maxRows, offset }));
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1435,7 +1441,7 @@ export async function fillFields(fields) {
|
||||
}
|
||||
|
||||
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). */
|
||||
export async function clickElement(text, { dblclick } = {}) {
|
||||
export async function clickElement(text, { dblclick, table } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(text); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
@@ -1515,8 +1521,16 @@ export async function clickElement(text, { dblclick } = {}) {
|
||||
let formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error(`clickElement: no form found`);
|
||||
|
||||
// Pre-resolve grid when table is specified
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
// Find the target element ID
|
||||
let target = await page.evaluate(findClickTargetScript(formNum, text));
|
||||
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||||
|
||||
// Retry: if not found, a modal form may still be loading (e.g. after F4).
|
||||
// Wait up to 2s for a new form to appear and re-detect.
|
||||
@@ -1526,7 +1540,7 @@ export async function clickElement(text, { dblclick } = {}) {
|
||||
const newForm = await page.evaluate(detectFormScript());
|
||||
if (newForm !== null && newForm !== formNum) {
|
||||
formNum = newForm;
|
||||
target = await page.evaluate(findClickTargetScript(formNum, text));
|
||||
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||||
if (!target?.error) break;
|
||||
}
|
||||
}
|
||||
@@ -2062,12 +2076,20 @@ export async function selectValue(fieldName, searchText, { type } = {}) {
|
||||
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
|
||||
* @returns {{ filled[], notFilled[]?, form }}
|
||||
*/
|
||||
export async function fillTableRow(fields, { tab, add, row } = {}) {
|
||||
export async function fillTableRow(fields, { tab, add, row, table } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('fillTableRow: no form found');
|
||||
|
||||
// Pre-resolve grid when table is specified
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Switch tab if requested
|
||||
if (tab) {
|
||||
@@ -2076,7 +2098,7 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
|
||||
|
||||
// 2. Add new row if requested
|
||||
if (add) {
|
||||
await clickElement('Добавить');
|
||||
await clickElement('Добавить', { table });
|
||||
// Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
|
||||
for (let aw = 0; aw < 6; aw++) {
|
||||
await page.waitForTimeout(150);
|
||||
@@ -2094,8 +2116,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
|
||||
if (row != null) {
|
||||
const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()));
|
||||
const cellCoords = await page.evaluate(`(() => {
|
||||
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
|
||||
const grid = grids[grids.length - 1];
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
@@ -2304,8 +2327,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
|
||||
if (info.filled) continue;
|
||||
// Find column for this key and dblclick on it
|
||||
const nextCoords = await page.evaluate(`(() => {
|
||||
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
|
||||
const grid = grids[grids.length - 1];
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
@@ -2383,8 +2407,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
|
||||
// Commit the edit: click on a different row (Escape cancels in tree grids).
|
||||
// Find the first visible row that is NOT the edited row and click it.
|
||||
const commitCoords = await page.evaluate(`(() => {
|
||||
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
|
||||
const grid = grids[grids.length - 1];
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
@@ -2937,8 +2962,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
|
||||
// Escape (e.g. from closeForm) would cancel the entire row.
|
||||
const commitTarget = await page.evaluate(`(() => {
|
||||
// Find the active grid
|
||||
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
|
||||
const grid = grids[grids.length - 1];
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
@@ -3005,12 +3031,20 @@ export async function fillTableRow(fields, { tab, add, row } = {}) {
|
||||
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||
* @returns {{ deleted, rowsBefore, rowsAfter, form }}
|
||||
*/
|
||||
export async function deleteTableRow(row, { tab } = {}) {
|
||||
export async function deleteTableRow(row, { tab, table } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('deleteTableRow: no form found');
|
||||
|
||||
// Pre-resolve grid when table is specified
|
||||
let gridSelector;
|
||||
if (table) {
|
||||
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||
gridSelector = resolved.gridSelector;
|
||||
}
|
||||
|
||||
// 1. Switch tab if requested
|
||||
if (tab) {
|
||||
await clickElement(tab);
|
||||
@@ -3019,8 +3053,9 @@ export async function deleteTableRow(row, { tab } = {}) {
|
||||
|
||||
// 2. Find the target row and click to select it
|
||||
const cellCoords = await page.evaluate(`(() => {
|
||||
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
|
||||
const grid = grids[grids.length - 1];
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return { error: 'no_grid_body' };
|
||||
@@ -3048,8 +3083,9 @@ export async function deleteTableRow(row, { tab } = {}) {
|
||||
|
||||
// 4. Count rows after deletion
|
||||
const rowsAfter = await page.evaluate(`(() => {
|
||||
const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0);
|
||||
const grid = grids[grids.length - 1];
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
|
||||
if (!grid) return 0;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
return body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
|
||||
@@ -185,24 +185,31 @@ const READ_FORM_FN = `function readForm(p) {
|
||||
}
|
||||
});
|
||||
|
||||
// Table/grid — pick the first VISIBLE grid (tab switching hides inactive grids)
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (grid) {
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) columns.push(text);
|
||||
});
|
||||
}
|
||||
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
result.table = { present: true, columns, rowCount };
|
||||
// Tables/grids — collect ALL visible grids
|
||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (allGrids.length > 0) {
|
||||
const tables = allGrids.map(grid => {
|
||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) columns.push(text);
|
||||
});
|
||||
}
|
||||
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
return { name, columns, rowCount };
|
||||
});
|
||||
result.tables = tables;
|
||||
// Backward compat: table = first grid summary
|
||||
const first = tables[0];
|
||||
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
|
||||
}
|
||||
|
||||
// Active filters (train badges above grid: *СостояниеПросмотра)
|
||||
@@ -356,18 +363,73 @@ export function readFormScript(formNum) {
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a specific grid by semantic name (table parameter).
|
||||
* Cascade: exact gridName match → gridName contains → column contains.
|
||||
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
|
||||
*/
|
||||
export function resolveGridScript(formNum, tableName) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const norm = s => (s || '').replace(/ё/gi, 'е');
|
||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
|
||||
const infos = allGrids.map((g, idx) => {
|
||||
const gridId = g.id || '';
|
||||
const gridName = gridId.replace(p, '');
|
||||
const head = g.querySelector('.gridHead');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) columns.push(text);
|
||||
});
|
||||
}
|
||||
return { idx, gridId, gridName, columns, el: g };
|
||||
});
|
||||
// 1. Exact gridName match (case-insensitive)
|
||||
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
|
||||
// 2. gridName contains target
|
||||
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
|
||||
// 3. Any column contains target
|
||||
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
|
||||
if (found) {
|
||||
return {
|
||||
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
|
||||
gridId: found.gridId,
|
||||
gridName: found.gridName,
|
||||
gridIndex: found.idx,
|
||||
columns: found.columns
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: 'not_found',
|
||||
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
|
||||
available: infos.map(i => ({ name: i.gridName, columns: i.columns }))
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read table/grid data with pagination.
|
||||
* Parses grid.innerText — \n separates rows, \t separates cells.
|
||||
* First row = column headers.
|
||||
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
|
||||
*/
|
||||
export function readTableScript(formNum, { maxRows = 20, offset = 0 } = {}) {
|
||||
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
|
||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||
|
||||
@@ -507,12 +569,14 @@ export function openCommandScript(name) {
|
||||
* Supports synonym matching: visible text AND internal name from DOM ID.
|
||||
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
||||
*/
|
||||
export function findClickTargetScript(formNum, text) {
|
||||
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const p = ${JSON.stringify(p)};
|
||||
const tableName = ${JSON.stringify(tableName || '')};
|
||||
const gridSelector = ${JSON.stringify(gridSelector || '')};
|
||||
const items = [];
|
||||
|
||||
// Buttons (a.press)
|
||||
@@ -561,6 +625,39 @@ export function findClickTargetScript(formNum, text) {
|
||||
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab' });
|
||||
});
|
||||
|
||||
// When table is specified, scope button search to grid's parent container
|
||||
if (gridSelector) {
|
||||
const gridEl = document.querySelector(gridSelector);
|
||||
if (gridEl) {
|
||||
// Find parent container that has id with formPrefix and contains the grid
|
||||
let container = gridEl.parentElement;
|
||||
while (container && container !== document.body) {
|
||||
if (container.id && container.id.startsWith(p)) break;
|
||||
container = container.parentElement;
|
||||
}
|
||||
// Filter items to those inside the container
|
||||
const containerItems = container && container !== document.body
|
||||
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
|
||||
: [];
|
||||
// Try fuzzy match within container first
|
||||
let cf = containerItems.find(i => i.name.toLowerCase() === target);
|
||||
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
|
||||
if (!cf) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
|
||||
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (cf) return { id: cf.id, kind: cf.kind, name: cf.name };
|
||||
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
|
||||
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
|
||||
if (gridName) {
|
||||
const prefixItems = items.filter(i => i.label && i.label.startsWith(gridName));
|
||||
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
|
||||
if (!pf) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (!pf) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
|
||||
if (pf) return { id: pf.id, kind: pf.kind, name: pf.name };
|
||||
}
|
||||
}
|
||||
// Fall through to unscoped search
|
||||
}
|
||||
|
||||
// Fuzzy match: exact name -> exact label -> startsWith name -> startsWith label -> includes name -> includes label
|
||||
let found = items.find(i => i.name.toLowerCase() === target);
|
||||
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
|
||||
|
||||
Reference in New Issue
Block a user