diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 42fd6266..6d13cdf4 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -170,6 +170,8 @@ const form = await getFormState(); #### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset, hasMore }` Read actual grid data with pagination. Each row is `{ columnName: value }`. +**Names from `columns` work everywhere** — `clickElement({ row, column })`, `fillTableRow`, `filterList` and `{ column: value }` row filters all resolve a column the same way `readTable` names it. Multi-row headers included: a header stacked over several sub-rows is reported (and targeted) as `'Имя 1'`, `'Имя 2'`, … — e.g. the accounting entries grid gives `'Субконто Дт 1'..'Субконто Дт 3'`. + | Option | Default | Description | |--------|---------|-------------| | `maxRows` | 20 | Max rows to return per call | diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index ab5396ef..03da0dec 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -1,4 +1,4 @@ -// web-test dom v1.19 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.20 — facade re-exporting injectable DOM scripts from dom/ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Facade: re-exports DOM selector & semantic mapping script generators. diff --git a/.claude/skills/web-test/scripts/dom/_shared.mjs b/.claude/skills/web-test/scripts/dom/_shared.mjs index 17aae668..e9b161ce 100644 --- a/.claude/skills/web-test/scripts/dom/_shared.mjs +++ b/.claude/skills/web-test/scripts/dom/_shared.mjs @@ -1,4 +1,4 @@ -// web-test dom shared v1.3 — embedded JS function constants +// web-test dom shared v1.4 — embedded JS function constants // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Shared function strings embedded into page.evaluate() generators. @@ -105,6 +105,209 @@ export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) { return cols; }`; +/** + * Single source of truth for columns of a grid WITH a header — the headed twin of + * synthHeaderlessColumns above, and for the same reason: a column name must map to the same + * physical cell for readers (readTable) and resolvers (click, row search, filter, fill). + * + * Column identity is `colindex` — 1С's own column id, present on both header boxes and body + * cells. Geometry is the FALLBACK, used only for cells that have no header of their own + * (sub-rows of a merged header, e.g. «Субконто Дт» over three stacked cells). + * + * Why colindex first: a wide header (ERP task list, «Исполнитель» spanning x 1085…1515) covers + * the narrow headers below it («Срок» 1085…1251, «Выполнена» 1251…1515). Matching a cell by its + * center-x alone puts the «Исполнитель» cell (center 1300) into the «Выполнена» group — which + * both fakes a merged header (phantom «Выполнена 1/2») and glues foreign values together. + * The write path (grid-edit.mjs) already resolves cells by colindex for exactly this reason. + * + * Column: { name, text, title, ci, x, right, y, h, fixed, kind?, subIdx? } + * - ci — anchor; null for expanded sub-columns (their cells carry a different colindex). + * - subIdx — set on «Имя 1/2/3» columns expanded from ONE header over several sub-rows; + * such a cell is found by its Y order inside the header's x-range. + */ +export const COLUMN_MODEL_FN = HEADERLESS_GRID_FN + ` +function picInfoShared(cell) { + if (!cell) return null; + if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null; + const dib = cell.querySelector('.gridBoxImg .dIB'); + if (!dib) return null; + const bg = dib.style.backgroundImage || ''; + if (!bg.includes('pictureCollection/picture/')) return null; + const m = bg.match(/[?&]gx=(\\d+)/); + return { gx: m ? m[1] : '0' }; +} + +function buildColumnModel(grid) { + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + const empty = { columns: [], byCi: {}, groups: new Map(), subRows: {}, multiRow: {}, headless: !head }; + if (!body) return empty; + + if (!head) { + const cols = synthHeaderlessColumns(grid).map(c => ({ + name: c.name, text: c.name, title: '', ci: c.colindex, subTarget: c.subTarget, + kind: c.kind, x: 0, right: 0, y: 0, h: 0, fixed: false, + })); + const byCi = {}; + cols.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; }); + return { columns: cols, byCi, groups: new Map(), subRows: {}, multiRow: {}, headless: true }; + } + + const headLine = head.querySelector('.gridLine') || head; + const lines = [...body.querySelectorAll('.gridLine')]; + const cellByCi = (line, ci) => [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === ci); + const columns = []; + + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const ci = box.getAttribute('colindex'); + const textEl = box.querySelector('.gridBoxText'); + const text = ((textEl || box).innerText || '').trim().replace(/\\n/g, ' '); + const title = (box.getAttribute('title') || '').trim(); + const r = box.getBoundingClientRect(); + const base = { ci, x: r.x, right: r.x + r.width, y: r.y, h: r.height, + fixed: box.classList.contains('gridBoxFix') }; + if (text) { columns.push(Object.assign(base, { name: text, text: text, title: title })); return; } + + // Unnamed header — a column only if its cells hold a checkbox or a picture. 1С doesn't + // expose the technical name, so it is named by the header tooltip. + // Sample SEVERAL rows: a picture bound to a Boolean draws nothing for false, so an empty + // first row is not evidence that the column has no pictures at all. + let kind = null; + for (const line of lines.slice(0, 10)) { + const cell = ci != null ? cellByCi(line, ci) : null; + if (!cell) continue; + if (cell.querySelector('.checkbox')) { kind = 'checkbox'; break; } + if (picInfoShared(cell)) { kind = 'picture'; break; } + } + if (!kind && picInfoShared(box)) kind = 'picture'; + if (!kind) return; + let name = kind === 'checkbox' ? '(checkbox)' : (title || '(picture)'); + if (columns.some(c => c.name === name)) { + let n = 2; + while (columns.some(c => c.name === name + ' ' + n)) n++; + name = name + ' ' + n; + } + columns.push(Object.assign(base, { name: name, text: '', title: title, kind: kind })); + }); + + const keyOf = c => Math.round(c.x) + ':' + Math.round(c.right); + const groups = new Map(); + columns.forEach(c => { const k = keyOf(c); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(c); }); + for (const hdrs of groups.values()) hdrs.sort((a, b) => a.y - b.y); + const byCi = {}; + columns.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; }); + + // Sub-rows per x-group, measured on the first data line. A cell belongs to the group of its + // OWN header whenever colindex says so; only header-less cells are placed geometrically. + const subRows = {}; + if (lines[0]) { + [...lines[0].children].forEach(box => { + if (box.offsetWidth === 0) return; + const ci = box.getAttribute('colindex'); + const own = ci != null ? byCi[ci] : null; + let key = null; + const r = box.getBoundingClientRect(); + if (own) key = keyOf(own); + else { + const cx = r.x + r.width / 2; + for (const [k, hdrs] of groups) { + if (cx >= hdrs[0].x && cx < hdrs[0].right) { key = k; break; } + } + } + if (key == null) return; + (subRows[key] = subRows[key] || []).push({ y: r.y }); + }); + Object.keys(subRows).forEach(k => subRows[k].sort((a, b) => a.y - b.y)); + } + + // Stacked headers (2+ over several sub-rows) → match by Y order. + // ONE header over several sub-rows → merged header: expand into «Имя 1..N». + const multiRow = {}; + for (const [k, hdrs] of groups) { + const subs = subRows[k]; + if (!subs || subs.length <= 1) continue; + if (hdrs.length >= 2) { multiRow[k] = hdrs; continue; } + const base = hdrs[0]; + const at = columns.indexOf(base); + columns.splice(at, 1); + if (base.ci != null && byCi[base.ci] === base) delete byCi[base.ci]; + const expanded = []; + for (let si = 0; si < subs.length; si++) { + const col = Object.assign({}, base, { + name: base.name + ' ' + (si + 1), ci: null, + y: base.y + si, h: base.h / subs.length, subIdx: si, + }); + columns.splice(at + si, 0, col); + expanded.push(col); + } + groups.set(k, expanded); + multiRow[k] = expanded; + } + + return { columns: columns, byCi: byCi, groups: groups, subRows: subRows, multiRow: multiRow, headless: false }; +} + +/** Cell → column. colindex first; geometry only for cells without a header of their own. */ +function columnForCell(model, box) { + const ci = box.getAttribute('colindex'); + if (ci != null && model.byCi[ci]) return model.byCi[ci]; + const r = box.getBoundingClientRect(); + const cx = r.x + r.width / 2; + const fixed = box.classList.contains('gridBoxFix'); + for (const k of Object.keys(model.multiRow)) { + const hdrs = model.multiRow[k]; + if (cx < hdrs[0].x || cx >= hdrs[0].right) continue; + const subs = model.subRows[k]; + if (subs) { + const si = subs.findIndex(s => Math.abs(s.y - r.y) < 5); + if (si >= 0 && si < hdrs.length) return hdrs[si]; + } + let best = hdrs[0], bd = Infinity; + for (const h of hdrs) { const d = Math.abs(r.y - h.y); if (d < bd) { bd = d; best = h; } } + return best; + } + return model.columns.find(c => cx >= c.x && cx < c.right && c.fixed === fixed) || null; +} + +/** Column → cell inside a given line. Mirror of columnForCell, same precedence. */ +function cellForColumn(model, line, col) { + const boxes = [...line.children].filter(b => b.offsetWidth > 0); + if (col.subIdx != null) { + const inGroup = boxes + .filter(b => { + const r = b.getBoundingClientRect(); + const cx = r.x + r.width / 2; + return cx >= col.x && cx < col.right && b.classList.contains('gridBoxFix') === col.fixed; + }) + .sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y); + return inGroup[col.subIdx] || null; + } + if (col.ci != null) { + const hit = boxes.find(b => b.getAttribute('colindex') === col.ci); + if (hit) return hit; + } + return boxes + .filter(b => b.classList.contains('gridBoxFix') === col.fixed) + .find(b => { + const r = b.getBoundingClientRect(); + const cx = r.x + r.width / 2; + return cx >= col.x && cx < col.right; + }) || null; +} + +/** Column by user-supplied name: exact → «Группа / Имя» suffix → substring. */ +function resolveColumnByName(model, name) { + const lo = s => (s || '').toLowerCase().replace(/ё/g, 'е').trim(); + const cand = c => [c.name, c.text, c.title].filter(Boolean); + const n = lo(name); + const suffix = lo(' / ' + name); + return model.columns.find(c => cand(c).some(t => lo(t) === n)) + || model.columns.find(c => cand(c).some(t => lo(t).endsWith(suffix))) + || model.columns.find(c => cand(c).some(t => lo(t).includes(n))) + || null; +}`; + /** Detect active form number. Picks form with most visible elements, skipping form0. * When modalSurface is visible — prefer the highest-numbered form (modal dialog). */ export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + ` diff --git a/.claude/skills/web-test/scripts/dom/filter.mjs b/.claude/skills/web-test/scripts/dom/filter.mjs index 043642c3..1e880656 100644 --- a/.claude/skills/web-test/scripts/dom/filter.mjs +++ b/.claude/skills/web-test/scripts/dom/filter.mjs @@ -1,5 +1,6 @@ -// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList +// web-test dom/filter v1.1 — DOM scripts for filterList / unfilterList // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { COLUMN_MODEL_FN } from './_shared.mjs'; /** * Find the first grid cell on the form and return its center coords. @@ -39,32 +40,26 @@ export function findColumnFirstCellCoordsScript(formNum, field) { const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] .find(g => g.offsetWidth > 0); if (!grid) return { error: 'no_grid' }; + ${COLUMN_MODEL_FN} const targetField = ${JSON.stringify(field)}; - const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; - let colIndex = -1; - let startsWithIdx = -1; - let includesIdx = -1; - for (let i = 0; i < headers.length; i++) { - const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); - if (!t) continue; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); - if (tl === fl) { colIndex = i; break; } - if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } - else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } - } - if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; if (!rows.length) return { error: 'no_rows' }; - if (colIndex < 0) { + + // Column resolution — shared model (dom/_shared.mjs), same as readTable/click. The old + // positional index (Nth header box → Nth cell box) breaks on multi-row headers, where + // header and cell counts and order differ. + const model = buildColumnModel(grid); + const col = resolveColumnByName(model, targetField); + if (!col) { + // Unknown column → click the first cell and let the caller fall back to DLB. const cells = [...rows[0].querySelectorAll('.gridBox')]; if (!cells.length) return { error: 'no_cells' }; - const r = cells[0].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; + const r0 = cells[0].getBoundingClientRect(); + return { x: Math.round(r0.x + r0.width / 2), y: Math.round(r0.y + r0.height / 2), needDlb: true }; } - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (colIndex >= cells.length) return { error: 'cell_not_found' }; - const r = cells[colIndex].getBoundingClientRect(); + const cell = cellForColumn(model, rows[0], col); + if (!cell) return { error: 'cell_not_found' }; + const r = cell.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`; } diff --git a/.claude/skills/web-test/scripts/dom/grid-edit.mjs b/.claude/skills/web-test/scripts/dom/grid-edit.mjs index 01450cc6..04aac211 100644 --- a/.claude/skills/web-test/scripts/dom/grid-edit.mjs +++ b/.claude/skills/web-test/scripts/dom/grid-edit.mjs @@ -1,7 +1,7 @@ -// web-test dom/grid-edit v1.2 — DOM scripts for row-fill (grid edit-time operations) +// web-test dom/grid-edit v1.3 — DOM scripts for row-fill (grid edit-time operations) // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // -import { HEADERLESS_GRID_FN } from './_shared.mjs'; +import { HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs'; // // All helpers below accept an optional `gridSelector`. When passed, they target // that exact grid; when null/undefined they pick the LAST visible `.grid` on @@ -67,45 +67,28 @@ export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) { */ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) { return `(() => { - ${HEADERLESS_GRID_FN} + ${COLUMN_MODEL_FN} const grid = ${gridResolver(gridSelector)}; if (!grid) return { error: 'no_grid' }; - const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); if (!body) return { error: 'no_grid_body' }; - // Read columns to find target colindex (+ subTarget for headerless split mark-boxes) - const cols = []; - if (head) { - const headLine = head.querySelector('.gridLine') || head; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null }); - }); - } else { - synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget })); - } - + // Column resolution — shared model (dom/_shared.mjs): same names and same physical cell + // as readTable/click report, including «Имя 1/2/3» of an expanded merged header. + const model = buildColumnModel(grid); const keys = ${JSON.stringify(fieldKeys)}; - let targetColindex = null, targetSub = null; + let targetCol = null; for (const key of keys) { - const exact = cols.find(c => c.text === key); - if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; break; } - const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); - if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; break; } + targetCol = resolveColumnByName(model, key); + if (targetCol) break; } + const targetSub = targetCol ? (targetCol.subTarget || null) : null; const rows = [...body.querySelectorAll('.gridLine')]; if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; const line = rows[${row}]; - // Find body cell by colindex (reliable across merged headers) - let box = null; - if (targetColindex != null) { - box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); - } + let box = targetCol ? cellForColumn(model, line, targetCol) : null; // Fallback: second visible box (skip checkbox/N column) if (!box) { const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); @@ -134,39 +117,30 @@ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) { */ export function findNextCellCoordsByKeyScript(gridSelector, row, key) { return `(() => { - ${HEADERLESS_GRID_FN} + ${COLUMN_MODEL_FN} const grid = ${gridResolver(gridSelector)}; if (!grid) return null; - const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); if (!body) return null; - const cols = []; - if (head) { - const headLine = head.querySelector('.gridLine') || head; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null }); - }); - } else { - synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget })); - } + const model = buildColumnModel(grid); const kl = ${JSON.stringify(key.toLowerCase())}; - const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); - let targetColindex = null, targetSub = null; - const exact = cols.find(c => c.text === kl); - if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; } - else { - const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) - || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); - if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; } + // Extra "no-space/no-dash" fuzz on top of the shared resolver: header «Группа Контрагентов» + // must match the key «ГруппаКонтрагентов». + let targetCol = resolveColumnByName(model, kl); + if (!targetCol) { + const squash = s => (s || '').toLowerCase().replace(/ё/g, 'е').replace(/[\\s\\-]+/g, ''); + const klNoSpace = squash(kl); + targetCol = model.columns.find(c => { + const t = squash(c.name); + return t && (t.includes(klNoSpace) || klNoSpace.includes(t)); + }) || null; } - if (targetColindex == null) return null; + if (!targetCol) return null; + const targetSub = targetCol.subTarget || null; const rows = [...body.querySelectorAll('.gridLine')]; if (${row} >= rows.length) return null; const line = rows[${row}]; - const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); + const box = cellForColumn(model, line, targetCol); if (!box) return null; box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); let cell; diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 36eea11e..9fe74aae 100644 --- a/.claude/skills/web-test/scripts/dom/grid.mjs +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -1,6 +1,6 @@ -// web-test dom/grid v1.13 — grid resolution + table reading + edit-time helpers +// web-test dom/grid v1.14 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills -import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.mjs'; +import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs'; import { ROW_STATE_FN } from './row-state.mjs'; /** @@ -95,7 +95,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto const m = bg.match(/[?&]gx=(\\d+)/); return { gx: m ? m[1] : '0' }; } - ${HEADERLESS_GRID_FN} + ${COLUMN_MODEL_FN} ${ROW_STATE_FN} // Write the leading row-state sprite onto a row: _rowPic (raw, always) + decoded booleans @@ -192,116 +192,10 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto return result; } - // Extract column headers with X-coordinates for alignment - const columns = []; - 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) { - // Unnamed column — check if data cells contain checkboxes or pictures. - // Picture columns have no header text (only an icon + a title tooltip); 1С - // doesn't expose the technical column name in the DOM, so we name them by - // the header's title attribute, falling back to '(picture)'. - const firstLine = body?.querySelector('.gridLine'); - const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0); - const idx = visibleHeaders.indexOf(box); - const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : []; - const r = box.getBoundingClientRect(); - if (cells[idx]?.querySelector('.checkbox')) { - columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); - } else if (picInfo(box) || picInfo(cells[idx])) { - let title = (box.getAttribute('title') || '').trim() || '(picture)'; - // Disambiguate duplicate picture-column names with a numeric suffix. - if (columns.some(c => c.text === title)) { - let n = 2; - while (columns.some(c => c.text === title + ' ' + n)) n++; - title = title + ' ' + n; - } - columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); - } - return; - } - const r = box.getBoundingClientRect(); - columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); - }); - - // Multi-row grid support: detect stacked/merged headers. - // Group headers by X-range. For each group, count data sub-rows from first line. - // - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order - // - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1") - const xGroups = new Map(); - columns.forEach(c => { - const key = Math.round(c.x) + ':' + Math.round(c.right); - if (!xGroups.has(key)) xGroups.set(key, []); - xGroups.get(key).push(c); - }); - for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y); - - const firstDataLine = body?.querySelector('.gridLine'); - const subRowMap = new Map(); - if (firstDataLine) { - [...firstDataLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const r = box.getBoundingClientRect(); - const cx = r.x + r.width / 2; - for (const [key, hdrs] of xGroups) { - const h0 = hdrs[0]; - if (cx >= h0.x && cx < h0.right) { - if (!subRowMap.has(key)) subRowMap.set(key, []); - subRowMap.get(key).push({ y: r.y }); - break; - } - } - }); - for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y); - } - - const multiRowGroups = new Map(); - for (const [key, hdrs] of xGroups) { - const subs = subRowMap.get(key); - if (!subs || subs.length <= 1) continue; - if (hdrs.length >= 2) { - multiRowGroups.set(key, hdrs); - } else if (hdrs.length === 1 && subs.length > 1) { - const base = hdrs[0]; - const baseIdx = columns.indexOf(base); - columns.splice(baseIdx, 1); - const expanded = []; - for (let si = 0; si < subs.length; si++) { - const numbered = { - text: base.text + ' ' + (si + 1), - x: base.x, w: base.w, right: base.right, - y: base.y + si, h: base.h / subs.length, _subIdx: si - }; - columns.splice(baseIdx + si, 0, numbered); - expanded.push(numbered); - } - multiRowGroups.set(key, expanded); - } - } - - function matchColumn(cellX, cellW, cellY) { - const cx = cellX + cellW / 2; - for (const [key, hdrs] of multiRowGroups) { - const h0 = hdrs[0]; - if (cx >= h0.x && cx < h0.right) { - const subs = subRowMap.get(key); - if (subs) { - const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5); - if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx]; - } - let best = hdrs[0], bestDist = Infinity; - for (const h of hdrs) { - const dist = Math.abs(cellY - h.y); - if (dist < bestDist) { bestDist = dist; best = h; } - } - return best; - } - } - return columns.find(c => cx >= c.x && cx < c.right); - } + // Columns + cell↔column mapping — single source of truth in dom/_shared.mjs. + // colindex first, geometry only for cells without a header of their own. + const model = buildColumnModel(grid); + const columns = model.columns; // Extract data rows from gridBody const allLines = body.querySelectorAll('.gridLine'); @@ -312,7 +206,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto const line = allLines[i]; if (!line) break; const row = {}; - columns.forEach(c => { row[c.text] = ''; }); + columns.forEach(c => { row[c.name] = ''; }); [...line.children].forEach(box => { if (box.offsetWidth === 0) return; const textEl = box.querySelector('.gridBoxText'); @@ -330,11 +224,9 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto else return; } } - // Match cell to column by X+Y overlap (multi-row aware) - const r = box.getBoundingClientRect(); - const col = matchColumn(r.x, r.width, r.y); + const col = columnForCell(model, box); if (col) { - row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val; + row[col.name] = row[col.name] ? row[col.name] + ' / ' + val : val; } }); // Detect row kind: group (gridListH), parent/up (gridListV), or element @@ -388,7 +280,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto hasMore = { below: body.scrollHeight > body.clientHeight }; } } - const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore }; + const result = { name, columns: columns.map(c => c.name), rows, total, offset: ${offset}, shown: rows.length, hasMore }; if (isTree) result.viewMode = 'tree'; if (hasGroups) result.hierarchical = true; return result; @@ -523,7 +415,7 @@ export function getSelectedOrLastRowIndexScript(gridSelector) { export function scanGridRowsScript(formNum, search) { return `(() => { ${ROW_CLICK_POINT_FN} - ${HEADERLESS_GRID_FN} + ${COLUMN_MODEL_FN} const p = 'form${formNum}_'; const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); if (!grid) return null; @@ -547,41 +439,14 @@ export function scanGridRowsScript(formNum, search) { if (!search || (isObj && !Object.keys(search).length)) { sel = lines[0]; matchKind = 'first'; } else if (isObj) { - // Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol. - const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead'); - let headers; - if (headLine) { - headers = [...headLine.children] - .filter(c => c.offsetWidth > 0) - .map(c => { - const t = (c.querySelector('.gridBoxText') || c).innerText || ''; - const title = c.getAttribute('title') || ''; - const r = c.getBoundingClientRect(); - return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width }; - }) - .filter(h => h.name); - } else { - // Headerless: synthesized columns anchored by colindex. - headers = synthHeaderlessColumns(grid).map(c => ({ name: c.name, text: c.name, title: '', x: 0, right: 0, colindex: c.colindex })); - } - const resolveCol = name => { - const n = norm(name); - const cand = h => [h.text, h.title].filter(Boolean); - return headers.find(h => cand(h).some(t => norm(t) === n)) - || headers.find(h => cand(h).some(t => norm(t).includes(n))); - }; - const cellAtCol = (line, col) => { - if (col.colindex != null) return visCells(line).find(b => b.getAttribute('colindex') === col.colindex); - return visCells(line).find(b => { - const r = b.getBoundingClientRect(); - const cx = r.x + r.width / 2; - return cx >= col.x && cx < col.right; - }); - }; + // Column resolution — shared model (dom/_shared.mjs): colindex first, geometry only + // for cells without a header of their own. Same names as readTable reports. + const model = buildColumnModel(grid); + const cellAtCol = (line, col) => cellForColumn(model, line, col); const keys = Object.keys(search); const cols = {}; for (const k of keys) { - const c = resolveCol(k); + const c = resolveColumnByName(model, k); if (!c) return { rowCount: lines.length, error: 'filter_column_not_found', column: k, visibleSample }; cols[k] = c; } @@ -664,66 +529,22 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) { const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); if (!body) return { error: 'no_grid_structure' }; - ${HEADERLESS_GRID_FN} + ${COLUMN_MODEL_FN} const isHeadless = !head; - // Header X-ranges (mirror of readTableScript logic, simplified). We also - // remember whether each header is frozen (gridBoxFix) — frozen and scrollable - // columns can share X coordinates after horizontal scroll, so cell matching - // must respect the frozen/scrollable partition. - let headers; - if (head) { - const headLine = head.querySelector('.gridLine') || head; - headers = [...headLine.children] - .filter(c => c.offsetWidth > 0) - .map(c => { - const textEl = c.querySelector('.gridBoxText'); - const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || ''; - // Picture/icon columns have no header text — fall back to the title tooltip - // (mirrors readTable naming) so they can still be targeted for clicking. - const title = (c.getAttribute('title') || '').trim(); - const r = c.getBoundingClientRect(); - return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') }; - }) - .filter(h => h.name); - } else { - // Headerless: synthesized columns anchored by colindex (cellAtColX matches by colindex). - headers = synthHeaderlessColumns(grid).map(c => ({ text: c.name, title: '', name: c.name, x: 0, right: 0, fixed: false, colindex: c.colindex, subTarget: c.subTarget })); - } - - const resolveCol = (name) => { - const suffix = ' / ' + name; - const cand = h => [h.text, h.title].filter(Boolean); - return headers.find(h => cand(h).some(t => lo(t) === lo(name))) - || headers.find(h => cand(h).some(t => t.endsWith(suffix))) - || headers.find(h => cand(h).some(t => lo(t).includes(lo(name)))); - }; + // Columns + name→column + column→cell — single source of truth in dom/_shared.mjs, + // shared with readTable, so a name it reports (incl. expanded «Имя 1/2/3») is clickable. + const model = buildColumnModel(grid); + const headers = model.columns; const targetCol = ${JSON.stringify(column)}; - const col = resolveCol(targetCol); + const col = resolveColumnByName(model, targetCol); if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) }; const lines = [...body.querySelectorAll('.gridLine')]; if (lines.length === 0) return { error: 'empty_grid' }; - // Match cell to column by X overlap, but only among cells with the same - // fixed/scrollable kind as the header. After horizontal scroll a scrollable - // cell may have the same x as a frozen one — without this guard cellAtColX - // would silently return the frozen cell for a scrollable header. - const cellAtColX = (line, c) => { - // Headerless columns carry colindex → match the body cell directly (robust, - // and returns the same box for both logical columns of a combined mark-box). - if (c.colindex != null) { - return [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === c.colindex); - } - return [...line.children] - .filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed) - .find(b => { - const r = b.getBoundingClientRect(); - const cx = r.x + r.width / 2; - return cx >= c.x && cx < c.right; - }); - }; + const cellAtColX = (line, c) => cellForColumn(model, line, c); const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || ''); const target = ${JSON.stringify(row)}; @@ -738,7 +559,7 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) { const entries = Object.entries(target); const colsByKey = {}; for (const [k] of entries) { - const c = resolveCol(k); + const c = resolveColumnByName(model, k); if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) }; colsByKey[k] = c; } diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md index 41501e55..e9e3e32c 100644 --- a/docs/web-test-guide.md +++ b/docs/web-test-guide.md @@ -252,7 +252,11 @@ console.log('Расшифровка:', JSON.stringify(drilldown.rows)); #### readTable — подробнее -Каждая строка — объект `{ columnName: value }`. Специальные поля для иерархии и дерева: +Каждая строка — объект `{ columnName: value }`. + +**Имена из `columns` годятся везде** — `clickElement({ row, column })`, `fillTableRow`, `filterList` и фильтры строки `{ колонка: значение }` разрешают колонку так же, как её назвал `readTable`. В том числе на многострочных шапках: шапка над несколькими под-рядами выводится (и адресуется) как `'Имя 1'`, `'Имя 2'`, … — например, грид проводок даёт `'Субконто Дт 1'..'Субконто Дт 3'`. + +Специальные поля для иерархии и дерева: - `_kind: 'group'` — группа в иерархическом списке - `_tree: 'expanded'|'collapsed'` — состояние узла дерева diff --git a/tests/web-test/22-row-state.test.mjs b/tests/web-test/22-row-state.test.mjs index 26c9b09d..be503785 100644 --- a/tests/web-test/22-row-state.test.mjs +++ b/tests/web-test/22-row-state.test.mjs @@ -38,6 +38,15 @@ export default async function({ navigateSection, openCommand, clickElement, clos // и потеряет состояние. Гард ниже — через DOM: колонка безымянная и в columns не // попадает (её наличие readTable определяет по первой строке, а там Posted=false → // картинки нет), поэтому проверить её через публичный API нельзя. + // Колонка безымянная (titleLocation:none) → readTable называет её '(picture)'. Её наличие + // определяется сэмплированием НЕСКОЛЬКИХ строк: picField над Posted не рисует картинку при + // Ложь, а первая строка списка — непроведённый документ. По одной первой строке колонка + // выпадала из columns целиком. + const tt = await readTable({ maxRows: 50 }); + assert.includes(tt.columns, '(picture)', 'безымянная picture-колонка есть в columns'); + const postedRow = tt.rows.find(r => r['Комментарий'] === 'StatePosted'); + assert.equal(postedRow['(picture)'], 'pic:0', 'у проведённого документа картинка нарисована'); + const page = await getPage(); const icons = await page.evaluate(() => { const grid = [...document.querySelectorAll('.grid')].find(g => g.offsetWidth > 0 && g.offsetHeight > 0); diff --git a/tests/web-test/24-multirow-header.test.mjs b/tests/web-test/24-multirow-header.test.mjs index 52ab1945..aaa3fe81 100644 --- a/tests/web-test/24-multirow-header.test.mjs +++ b/tests/web-test/24-multirow-header.test.mjs @@ -13,7 +13,20 @@ export const timeout = 120000; // шапок у них НЕТ. Здесь разворот в «Субконто 1/2/3» — правильное поведение, его // обязана сохранить любая правка. -export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillTableRow, assert, step, log }) { +export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillTableRow, getPage, assert, step, log }) { + + // Куда клик попал НА САМОМ ДЕЛЕ. Результат clickElement возвращает запрошенное имя колонки + // (эхо), а не разрешённую ячейку, — по нему промах неотличим от попадания. Нажатая ячейка + // помечается в DOM классами select+focus. + const focusedCell = async () => { + const page = await getPage(); + return page.evaluate(() => { + const grid = [...document.querySelectorAll('.grid')].find(g => g.offsetWidth > 0 && g.offsetHeight > 0); + const box = [...grid.querySelectorAll('.gridBody [colindex]')] + .find(b => b.classList.contains('select') && b.classList.contains('focus')); + return box ? { ci: box.getAttribute('colindex'), text: (box.innerText || '').trim() } : null; + }); + }; await step('setup: открыть обработку', async () => { await navigateSection('Склад'); @@ -53,24 +66,39 @@ export default async function({ navigateSection, openCommand, clickElement, clos assert.equal(r['Субконто 3'], 'Субконто3 1', 'Субконто 3'); }); - await step('click: clickElement({row, column}) попадает в нужную колонку', async () => { + await step('click: clickElement({row, column}) попадает в нужную ячейку', async () => { const res = await clickElement({ row: { 'Код': 'К2' }, column: 'Срок' }); - log(`clicked: ${JSON.stringify(res.clicked)}`); assert.equal(res.clicked?.kind, 'gridCell', 'kind=gridCell'); - assert.equal(res.clicked?.column, 'Срок', 'column=Срок'); + // Проверяем факт, а не эхо: до правки клик молча уходил в «Исполнитель 2» и рапортовал успех. + const cell = await focusedCell(); + log(`focused: ${JSON.stringify(cell)}`); + assert.ok(cell, 'нажатая ячейка помечена в DOM'); + assert.equal(cell.text, 'Срок 2', `клик попал в ячейку «Срок» строки К2, а не в соседнюю (got «${cell.text}»)`); }); await step('click: имя колонки из readTable пригодно для клика (единство именования)', async () => { const t = await readTable(); assert.includes(t.columns, 'Субконто 2', 'имя развёрнутой колонки из readTable'); const res = await clickElement({ row: { 'Код': 'К2' }, column: 'Субконто 2' }); - log(`clicked: ${JSON.stringify(res.clicked)}`); assert.equal(res.clicked?.kind, 'gridCell', 'по имени из readTable клик находит ячейку'); + const cell = await focusedCell(); + log(`focused: ${JSON.stringify(cell)}`); + assert.equal(cell?.text, 'Субконто2 2', `клик попал во второй под-ряд «Субконто» (got «${cell?.text}»)`); }); - await step('fill: fillTableRow пишет значение в нужную колонку', async () => { - // Путь записи ищет ячейку по colindex (grid-edit.mjs), поэтому ожидается зелёным - // и до правки резолверов чтения/клика — это замер, а не регресс. + await step('row-filter: строка ищется по «украденной» колонке', async () => { + // Фильтр строки идёт через свой резолвер (findRowInGridScript) — тоже геометрия по x, + // причём без Y-логики. Ищем строку по «Срок», чью ячейку ворует широкий «Исполнитель». + const res = await clickElement({ row: { 'Срок': 'Срок 2' }, column: 'Код' }); + assert.equal(res.clicked?.kind, 'gridCell', 'строка найдена по значению узкой колонки'); + const cell = await focusedCell(); + log(`focused: ${JSON.stringify(cell)}`); + assert.equal(cell?.text, 'К2', `найдена именно строка К2 (got «${cell?.text}»)`); + }); + + await step('fill: fillTableRow пишет в колонку со своей шапкой', async () => { + // Путь записи ищет ячейку по colindex (grid-edit.mjs) — ожидается зелёным и до правки + // резолверов чтения/клика. Это замер, а не регресс. await fillTableRow({ 'Срок': 'Срок изменён' }, { row: { 'Код': 'К3' } }); const t = await readTable(); const row = t.rows.find(r => r['Код'] === 'К3'); @@ -80,6 +108,19 @@ export default async function({ navigateSection, openCommand, clickElement, clos assert.equal(row['Выполнена'], 'Выполнена 3', 'соседняя узкая колонка не задета'); }); + await step('fill: fillTableRow пишет в РАЗВЁРНУТУЮ колонку (шапки у ячейки нет)', async () => { + // «Субконто 2» — под-ряд объединённой шапки: своей шапки у ячейки нет, у группы ci=6, + // а ячейки идут ci=7/8/9. Проверяем, что запись попадает во второй под-ряд, а не в + // первый и не в соседа. + await fillTableRow({ 'Субконто 2': 'СК2 изменён' }, { row: { 'Код': 'К1' } }); + const t = await readTable(); + const row = t.rows.find(r => r['Код'] === 'К1'); + log(`after fill: ${JSON.stringify(row)}`); + assert.equal(row['Субконто 2'], 'СК2 изменён', 'значение попало в «Субконто 2»'); + assert.equal(row['Субконто 1'], 'Субконто1 1', '«Субконто 1» не задета'); + assert.equal(row['Субконто 3'], 'Субконто3 1', '«Субконто 3» не задета'); + }); + await step('cleanup: закрыть форму', async () => { await closeForm(); });