From 7c754241538f0dbf571418a6eff08451c1424ede Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 28 Jun 2026 18:41:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20=D0=BF=D0=BE=D0=B4=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20=D0=B3=D1=80=D0=B8=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B1=D0=B5=D0=B7=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20?= =?UTF-8?q?(headerless=20grids)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единый источник деривации колонок для безголовых гридов (без .gridHead): synthHeaderlessColumns (HEADERLESS_GRID_FN в _shared.mjs) — привязка по colindex, имена Колонка{N}/(checkbox)/(picture), расщепление комбинированной марк-ячейки (галка+текст в одной .gridBox) на (checkbox)+Колонка1 через subTarget. Ветки head ? существующее : synth добавлены в: - readTableScript, findGridCellScript, scanGridRowsScript, findGridHeadCenterCoordsScript (grid.mjs) - findCellCoordsByFieldsScript, findNextCellCoordsByKeyScript, sortFieldKeysByColindexScript, readActiveGridCellScript (grid-edit.mjs) - сводка таблиц в READ_FORM_FN (_shared.mjs) Гейт — неизменная истинность head, headed-путь нетронут. Регресс 23/23 зелёный. Новый tests/web-test/21-headerless.test.mjs. Live на БезшапочнаяТаблица + МножественныйВыбор: readTable/getFormState/fillTableRow(тоггл (checkbox))/clickElement. Follow-up: правка data-ячейки на безголовом ValueTable (edit-Tab-loop row-fill). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../skills/web-test/scripts/dom/_shared.mjs | 70 +++++- .../skills/web-test/scripts/dom/grid-edit.mjs | 103 ++++++--- .claude/skills/web-test/scripts/dom/grid.mjs | 199 ++++++++++++++---- tests/web-test/21-headerless.test.mjs | 92 ++++++++ 4 files changed, 388 insertions(+), 76 deletions(-) create mode 100644 tests/web-test/21-headerless.test.mjs diff --git a/.claude/skills/web-test/scripts/dom/_shared.mjs b/.claude/skills/web-test/scripts/dom/_shared.mjs index a39ac6a8..2d1f652d 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.1 — embedded JS function constants +// web-test dom shared v1.2 — embedded JS function constants // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Shared function strings embedded into page.evaluate() generators. @@ -43,6 +43,68 @@ export const ROW_CLICK_POINT_FN = `function rowClickPoint(line, body) { return { x: Math.round(pick.r.x + Math.min(pick.r.width / 2, 60)), y: Math.round(pick.r.y + pick.r.height / 2) }; }`; +/** + * Single source of truth for column derivation on HEADERLESS grids (no `.gridHead`). + * 1C still puts `colindex` on body cells, so anchoring works without a header. + * Returns ordered descriptors consumed identically by readers (readTable, getFormState) + * and resolvers (findCellCoords, findGridCell, scanGridRows) so a synthesized name like + * "Колонка1" always maps to the same physical cell on both read and write. + * + * Descriptor: { name, kind:'data'|'checkbox'|'picture', colindex, subTarget:'checkbox'|'title'|'text'|null } + * - colindex — anchor: find the cell via line.children box with matching getAttribute('colindex'). + * - subTarget — node inside that box: 'checkbox' → .checkbox, 'title' → .gridBoxTitle, + * 'text' → .gridBoxText, null → box itself. + * + * A COMBINED mark-box (one box holding BOTH .checkbox AND non-empty .gridBoxTitle, e.g. the + * value-list checkbox mark-lists) is split into TWO logical columns sharing one colindex: + * "(checkbox)" (subTarget:checkbox) + "КолонкаN" (subTarget:title). Data columns are numbered + * КолонкаN among themselves (checkbox/picture don't consume a number); duplicate + * "(checkbox)"/"(picture)" get a " 2", " 3" suffix. + */ +export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) { + function picInfo(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' }; + } + const body = grid.querySelector('.gridBody'); + if (!body) return []; + const line = body.querySelector('.gridLine'); + if (!line) return []; + const cols = []; + let dataN = 0; + const uniq = (base) => { + if (!cols.some(c => c.name === base)) return base; + let n = 2; while (cols.some(c => c.name === base + ' ' + n)) n++; + return base + ' ' + n; + }; + [...line.children].forEach(box => { + if (box.offsetWidth === 0) return; + const ci = box.getAttribute('colindex'); + if (ci == null) return; + const chk = box.querySelector('.checkbox'); + const titleEl = box.querySelector('.gridBoxTitle'); + const textEl = box.querySelector('.gridBoxText'); + const titleTxt = ((titleEl ? titleEl.innerText : '') || '').trim(); + if (chk && titleTxt) { + cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' }); + cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: 'title' }); + } else if (chk) { + cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' }); + } else if (picInfo(box)) { + cols.push({ name: uniq('(picture)'), kind: 'picture', colindex: ci, subTarget: null }); + } else { + cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: textEl ? 'text' : (titleEl ? 'title' : null) }); + } + }); + return cols; +}`; + /** 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 + ` @@ -80,7 +142,8 @@ function detectForms() { }`; /** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */ -export const READ_FORM_FN = `function readForm(p) { +export const READ_FORM_FN = HEADERLESS_GRID_FN + ` +function readForm(p) { const result = {}; const fields = []; const buttons = []; @@ -317,6 +380,9 @@ export const READ_FORM_FN = `function readForm(p) { } } } + } else if (body) { + // Headerless grid — synthesize columns by colindex (single source). + synthHeaderlessColumns(grid).forEach(c => columns.push({ text: c.name, x: 0, right: 0, y: 0, h: 0 })); } const colNames = columns.map(c => c.text); const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; diff --git a/.claude/skills/web-test/scripts/dom/grid-edit.mjs b/.claude/skills/web-test/scripts/dom/grid-edit.mjs index 3e480445..edcff9d6 100644 --- a/.claude/skills/web-test/scripts/dom/grid-edit.mjs +++ b/.claude/skills/web-test/scripts/dom/grid-edit.mjs @@ -1,6 +1,8 @@ -// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations) +// web-test dom/grid-edit v1.1 — 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'; +// // All helpers below accept an optional `gridSelector`. When passed, they target // that exact grid; when null/undefined they pick the LAST visible `.grid` on // the page (this matches the implicit "current grid" used by row-fill). @@ -24,18 +26,23 @@ function gridResolver(gridSelector) { */ export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) { return `(() => { + ${HEADERLESS_GRID_FN} const grid = ${gridResolver(gridSelector)}; if (!grid) return null; const head = grid.querySelector('.gridHead'); - if (!head) return null; - const headLine = head.querySelector('.gridLine') || head; const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); - const ci = parseInt(box.getAttribute('colindex') || '-1'); - if (t) cols.push({ text: t, colindex: ci }); - }); + if (head) { + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); + const ci = parseInt(box.getAttribute('colindex') || '-1'); + if (t) cols.push({ text: t, colindex: ci }); + }); + } else { + // Headerless: synthesized columns (КолонкаN/(checkbox)) ordered by colindex + synthHeaderlessColumns(grid).forEach(c => cols.push({ text: c.name.toLowerCase(), colindex: parseInt(c.colindex) })); + } const keys = ${JSON.stringify(fieldKeys)}; const mapped = keys.map(k => { const exact = cols.find(c => c.text === k); @@ -60,29 +67,34 @@ export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) { */ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) { return `(() => { + ${HEADERLESS_GRID_FN} const grid = ${gridResolver(gridSelector)}; if (!grid) return { error: 'no_grid' }; const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); - if (!head || !body) return { error: 'no_grid_body' }; + if (!body) return { error: 'no_grid_body' }; - // Read column headers to find target colindex - const headLine = head.querySelector('.gridLine') || head; + // Read columns to find target colindex (+ subTarget for headerless split mark-boxes) const cols = []; - [...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() }); - }); + 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 keys = ${JSON.stringify(fieldKeys)}; - let targetColindex = null; + let targetColindex = null, targetSub = null; for (const key of keys) { const exact = cols.find(c => c.text === key); - if (exact) { targetColindex = exact.colindex; break; } + 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; break; } + if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; break; } } const rows = [...body.querySelectorAll('.gridLine')]; @@ -101,7 +113,12 @@ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) { } if (!box) return { error: 'no_cell' }; box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; + // subTarget-aware cell node: 'checkbox' → box itself (row-fill then uses findCheckboxAtPoint), + // 'title' → .gridBoxTitle, else (headed default / 'text') → .gridBoxText || box. + let cell; + if (targetSub === 'checkbox') cell = box; + else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box; + else cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; @@ -117,28 +134,33 @@ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) { */ export function findNextCellCoordsByKeyScript(gridSelector, row, key) { return `(() => { + ${HEADERLESS_GRID_FN} const grid = ${gridResolver(gridSelector)}; if (!grid) return null; const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); - if (!head || !body) return null; - const headLine = head.querySelector('.gridLine') || head; + if (!body) return null; const cols = []; - [...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() }); - }); + 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 kl = ${JSON.stringify(key.toLowerCase())}; const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); - let targetColindex = null; + let targetColindex = null, targetSub = null; const exact = cols.find(c => c.text === kl); - if (exact) targetColindex = exact.colindex; + 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; + if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; } } if (targetColindex == null) return null; const rows = [...body.querySelectorAll('.gridLine')]; @@ -147,7 +169,10 @@ export function findNextCellCoordsByKeyScript(gridSelector, row, key) { const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); if (!box) return null; box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; + let cell; + if (targetSub === 'checkbox') cell = box; + else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box; + else cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; @@ -234,6 +259,7 @@ export function getGridEditCheckScript() { */ export function readActiveGridCellScript() { return `(() => { + ${HEADERLESS_GRID_FN} const f = document.activeElement; if (!f) return { tag: 'none' }; if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { @@ -254,6 +280,15 @@ export function readActiveGridCellScript() { break; } } + if (!head) { + // Headerless: resolve the editing column name from the cell's colindex via synth. + let box = f; while (box && !box.classList?.contains('gridBox')) box = box.parentElement; + const ci = box?.getAttribute('colindex'); + if (ci != null) { + const sc = synthHeaderlessColumns(grid).find(c => c.kind === 'data' && c.colindex === ci); + if (sc) headerText = sc.name; + } + } } // Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB), // or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора). diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 961df406..8903a508 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.11 — grid resolution + table reading + edit-time helpers +// web-test dom/grid v1.12 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills -import { ROW_CLICK_POINT_FN } from './_shared.mjs'; +import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.mjs'; /** * Resolve a specific grid by semantic name (table parameter). @@ -94,18 +94,92 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto const m = bg.match(/[?&]gx=(\\d+)/); return { gx: m ? m[1] : '0' }; } + ${HEADERLESS_GRID_FN} // DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); - if (!head || !body) { - // Fallback: innerText-based (for non-standard grids) + if (!body) { + // Fallback: innerText-based (for non-standard grids without a body) const gText = grid.innerText?.trim() || ''; const lines = gText.split('\\n').filter(Boolean); return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0, hint: 'Grid has no gridHead/gridBody structure' }; } + // HEADERLESS grid (body but no .gridHead) — synthesize columns by colindex. + // Single source: synthHeaderlessColumns. Headed path below is left untouched. + if (!head) { + const synth = synthHeaderlessColumns(grid); + const colNames = synth.map(c => c.name); + const allLines = body.querySelectorAll('.gridLine'); + const total = allLines.length; + const rows = []; + const end = Math.min(${offset} + ${maxRows}, total); + for (let i = ${offset}; i < end; i++) { + const line = allLines[i]; + if (!line) break; + const row = {}; + const boxes = [...line.children].filter(b => b.offsetWidth > 0); + synth.forEach(c => { + const box = boxes.find(b => b.getAttribute('colindex') === c.colindex); + let val = ''; + if (box) { + if (c.subTarget === 'checkbox') { + const chk = box.querySelector('.checkbox'); + val = chk && chk.classList.contains('select') ? 'true' : 'false'; + } else if (c.subTarget === 'title') { + val = (box.querySelector('.gridBoxTitle')?.innerText || '').trim().replace(/\\n/g, ' '); + } else if (c.subTarget === 'text') { + val = (box.querySelector('.gridBoxText')?.innerText || '').trim().replace(/\\n/g, ' '); + } else { + const pic = picInfo(box); + val = pic ? 'pic:' + pic.gx : ((box.innerText || '').trim().replace(/\\n/g, ' ')); + } + } + row[c.name] = val; + }); + // Row meta — mirrors the headed path (group/parent/tree/level/selected) + const imgBox = line.querySelector('.gridBoxImg'); + if (imgBox) { + if (imgBox.querySelector('.gridListH')) row._kind = 'group'; + else if (imgBox.querySelector('.gridListV')) row._kind = 'parent'; + } + const treeBox = line.querySelector('.gridBoxTree'); + if (treeBox) { + const treeIcon = imgBox?.querySelector('[tree="true"]'); + if (treeIcon) { const bg = treeIcon.style.backgroundImage || ''; row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed'; } + row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0; + } + if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true; + rows.push(row); + } + // hasMore — mirrors the headed path + let hasMore; + const turnsBox = document.getElementById('vertButtonScroll_' + grid.id); + if (turnsBox && turnsBox.offsetHeight > 0) { + const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]'); + const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]'); + hasMore = { above: [...upBtns].some(b => !b.classList.contains('disabled')), + below: [...dnBtns].some(b => !b.classList.contains('disabled')) }; + } else { + const vs = document.getElementById('vertScroll_' + grid.id); + if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) { + const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0; + const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0; + hasMore = { above: back > 0, below: next > 0 }; + } else { + hasMore = { below: body.scrollHeight > body.clientHeight }; + } + } + const isTree = !!body.querySelector('.gridBoxTree'); + const hasGroups = rows.some(r => r._kind === 'group'); + const result = { name, columns: colNames, rows, total, offset: ${offset}, shown: rows.length, hasMore }; + if (isTree) result.viewMode = 'tree'; + if (hasGroups) result.hierarchical = true; + return result; + } + // Extract column headers with X-coordinates for alignment const columns = []; const headLine = head.querySelector('.gridLine') || head; @@ -381,7 +455,13 @@ export function findGridHeadCenterCoordsScript(gridSelector) { const grid = ${gridResolver(gridSelector)}; if (!grid) return null; const head = grid.querySelector('.gridHead'); - if (!head) return null; + if (!head) { + // Headerless editable grid: no header to click for commit-defocus. Click the + // thin strip at the grid's very top edge (above the first row) so the active + // edit commits without landing on a .gridLine (which would re-enter edit). + const gr = grid.getBoundingClientRect(); + return { x: Math.round(gr.x + gr.width / 2), y: Math.round(gr.y + 1) }; + } const r = head.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`; @@ -429,6 +509,7 @@ export function getSelectedOrLastRowIndexScript(gridSelector) { export function scanGridRowsScript(formNum, search) { return `(() => { ${ROW_CLICK_POINT_FN} + ${HEADERLESS_GRID_FN} const p = 'form${formNum}_'; const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); if (!grid) return null; @@ -454,26 +535,35 @@ export function scanGridRowsScript(formNum, search) { } else if (isObj) { // Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol. const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead'); - const headers = [...(headLine ? 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); + 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) => visCells(line).find(b => { - const r = b.getBoundingClientRect(); - const cx = r.x + r.width / 2; - return cx >= col.x && cx < col.right; - }); + 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; + }); + }; const keys = Object.keys(search); const cols = {}; for (const k of keys) { @@ -559,25 +649,33 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) { if (!grid) return { error: 'no_grid' }; const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); - if (!head || !body) return { error: 'no_grid_structure' }; + if (!body) return { error: 'no_grid_structure' }; + ${HEADERLESS_GRID_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. - const headLine = head.querySelector('.gridLine') || head; - const 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); + 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; @@ -598,13 +696,20 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) { // 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) => [...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) => { + // 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 cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || ''); const target = ${JSON.stringify(row)}; @@ -644,6 +749,20 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) { const cell = cellAtColX(line, col); if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx }; + // Headerless: click coords target the subTarget node (checkbox image / title), + // no frozen/scroll partition in these narrow grids → trivially visible. + if (isHeadless) { + let node = cell; + if (col.subTarget === 'checkbox') node = cell.querySelector('.checkbox') || cell; + else if (col.subTarget === 'title') node = cell.querySelector('.gridBoxTitle') || cell; + else if (col.subTarget === 'text') node = cell.querySelector('.gridBoxText') || cell; + const rr = node.getBoundingClientRect(); + const gb = grid.getBoundingClientRect(); + return { x: Math.round(rr.x + rr.width / 2), y: Math.round(rr.y + rr.height / 2), + cellX: Math.round(rr.x), cellRight: Math.round(rr.x + rr.width), + gridX: Math.round(gb.x), gridRight: Math.round(gb.x + gb.width), scrollableLeft: Math.round(gb.x), + columnText: col.name, rowIdx, isFixed: false, cellText: cellText(cell), visible: true }; + } const r = cell.getBoundingClientRect(); const gridBox = grid.getBoundingClientRect(); // Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even diff --git a/tests/web-test/21-headerless.test.mjs b/tests/web-test/21-headerless.test.mjs new file mode 100644 index 00000000..44f992c4 --- /dev/null +++ b/tests/web-test/21-headerless.test.mjs @@ -0,0 +1,92 @@ +export const name = 'headerless grids: readTable / getFormState / fillTableRow / clickElement без шапки'; +export const tags = ['headerless', 'table', 'smoke']; +export const timeout = 180000; + +// Покрывает поддержку гридов без .gridHead (фаза 2 мультивыбора): +// - БезшапочнаяТаблица (ValueTable, отдельная колонка-чекбокс) +// - МножественныйВыбор → Через флажки (марк-список, комбинированная ячейка галка+текст) +// Деривация колонок едина (synthHeaderlessColumns): Колонка{N} + (checkbox), привязка по colindex. +// Правка data-ячейки на безголовом ValueTable — follow-up (edit-Tab-loop row-fill), здесь не тестируется. + +export default async function({ + navigateLink, clickElement, fillTableRow, readTable, getFormState, wait, assert, step, log, closeForm +}) { + + // ── ValueTable (отдельная колонка-чекбокс) ────────────────────────────────── + await step('readTable: ValueTable без шапки → Колонка1..3 + (checkbox)', async () => { + await navigateLink('Обработка.БезшапочнаяТаблица'); + await wait(1.2); + const t = await readTable({ maxRows: 10 }); + log(`columns: ${JSON.stringify(t.columns)}`); + assert.deepEqual(t.columns, ['Колонка1', 'Колонка2', 'Колонка3', '(checkbox)'], + 'синтетические колонки по colindex'); + assert.equal(t.total, 3, '3 строки'); + assert.equal(t.rows[0]['Колонка1'], 'Позиция 001', 'текст data-колонки'); + assert.ok(t.rows[0]['(checkbox)'] === 'true' || t.rows[0]['(checkbox)'] === 'false', + '(checkbox) читается как true/false'); + }); + + await step('getFormState: сводка таблицы показывает synth-колонки + rowCount', async () => { + const fs = await getFormState(); + const tbl = (fs.tables || []).find(x => (x.columns || []).includes('(checkbox)')); + log(`tables: ${JSON.stringify(fs.tables)}`); + assert.ok(tbl, 'таблица с (checkbox) в сводке'); + assert.deepEqual(tbl.columns, ['Колонка1', 'Колонка2', 'Колонка3', '(checkbox)'], 'колонки сводки совпадают с readTable'); + assert.equal(tbl.rowCount, 3, 'rowCount'); + }); + + await step('fillTableRow: тоггл (checkbox) по индексу строки', async () => { + const before = (await readTable({ maxRows: 5 })).rows.map(r => r['(checkbox)']); + const target = before[1] === 'true' ? 'false' : 'true'; // строка 1 — переключаем в противоположное + const r = await fillTableRow({ '(checkbox)': target }, { row: 1 }); + log(`filled: ${JSON.stringify(r.filled)}`); + assert.ok(r.filled?.[0]?.ok && r.filled[0].method === 'toggle', 'method=toggle, ok'); + const after = (await readTable({ maxRows: 5 })).rows.map(r => r['(checkbox)']); + assert.equal(after[1], target, `строка 1 переключена в ${target}`); + }); + + await step('fillTableRow: тоггл (checkbox) по фильтру {Колонка1}', async () => { + const r = await fillTableRow({ '(checkbox)': 'true' }, { row: { 'Колонка1': 'Позиция 002' } }); + log(`filled: ${JSON.stringify(r.filled)}`); + assert.ok(r.filled?.[0]?.ok, 'ok через row-фильтр {Колонка1}'); + const t = await readTable({ maxRows: 5 }); + const row = t.rows.find(x => x['Колонка1'] === 'Позиция 002'); + assert.equal(row['(checkbox)'], 'true', 'Позиция 002 отмечена'); + }); + + await step('clickElement: клик по ячейке (checkbox) тогглит галку', async () => { + const t0 = await readTable({ maxRows: 5 }); + const r0 = t0.rows.find(x => x['Колонка1'] === 'Позиция 003'); + const want = r0['(checkbox)'] === 'true' ? 'false' : 'true'; + await clickElement({ row: { 'Колонка1': 'Позиция 003' }, column: '(checkbox)' }); + await wait(0.4); + const t1 = await readTable({ maxRows: 5 }); + const r1 = t1.rows.find(x => x['Колонка1'] === 'Позиция 003'); + assert.equal(r1['(checkbox)'], want, `клик переключил галку Позиция 003 в ${want}`); + }); + + // ── Марк-список (комбинированная ячейка галка+текст в одной .gridBox) ──────── + await step('марк-список: readTable → (checkbox) + Колонка1, расщепление одной ячейки', async () => { + await navigateLink('Обработка.МножественныйВыбор'); + await clickElement('Через флажки'); + await wait(1.2); + const t = await readTable({ maxRows: 10 }); + log(`columns: ${JSON.stringify(t.columns)} rows: ${JSON.stringify(t.rows)}`); + assert.deepEqual(t.columns, ['(checkbox)', 'Колонка1'], 'комбинированная ячейка → 2 колонки'); + assert.ok(t.rows.some(r => r['Колонка1'] === 'Альфа'), 'Альфа в Колонка1'); + assert.ok(t.rows.every(r => r['(checkbox)'] === 'true' || r['(checkbox)'] === 'false'), '(checkbox) булев'); + }); + + await step('марк-список: fillTableRow отмечает по фильтру {Колонка1: Альфа}', async () => { + const r = await fillTableRow({ '(checkbox)': 'true' }, { row: { 'Колонка1': 'Альфа' } }); + log(`filled: ${JSON.stringify(r.filled)}`); + assert.ok(r.filled?.[0]?.ok && r.filled[0].method === 'toggle', 'method=toggle, ok'); + const t = await readTable({ maxRows: 10 }); + const alpha = t.rows.find(x => x['Колонка1'] === 'Альфа'); + assert.equal(alpha['(checkbox)'], 'true', 'Альфа отмечена'); + }); + + await step('cleanup: закрыть форму ввода значений', async () => { + await closeForm({ save: false }); + }); +}