feat(web-test): поддержка гридов без шапки (headerless grids)

Единый источник деривации колонок для безголовых гридов (без .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) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-28 18:41:55 +03:00
parent 08ea3f0806
commit 7c75424153
4 changed files with 388 additions and 76 deletions
@@ -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;
@@ -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. НачалоВыбора).
+159 -40
View File
@@ -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
+92
View File
@@ -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 });
});
}