/**
|
* 探针:分组检验项表(GroupedQualityTable)的真实渲染产物验证。
|
*
|
* 只读,不改代码、不碰数据库。用法:npx tsx .qc-conformance/verify-grouped-table.ts
|
*
|
* 守三件事:
|
* 1. 组名格「合并几行」与「子项行让位」都由数据下发(rowspan / hidden)——
|
* 渲染期算不出来:路径在数组上继续取属性会按元素 pluck,`group.children.length` 取不到值;
|
* 2. 让位后的行仍要占满 7 列、不串位。这是「隐藏一格会不会把整行剩余格子左移一列」的真问题,
|
* HTML 结构层面只能验到「td 数」,真正的表格布局由浏览器定,所以脚本把渲染产物写到
|
* .qc-conformance/group-table-render.html,交给 Playwright 实测每一格的列位置;
|
* 3. 组头锚点行与过程参数(无判定规则)不能把整份报告的结论压下去。
|
*
|
* 组件结构断言直接打在真实 buildContent 的产物上:结构一改(列数、绑定路径、合并属性搬家),
|
* 这里就红,不必等浏览器。
|
*/
|
import type { ReportContextItem } from '../src/components/quality/engine/context';
|
import type { RenderNode, RenderSchema } from '../src/components/quality/engine/render';
|
|
import { writeFileSync } from 'node:fs';
|
|
import { registerAllQualityComponents } from '../src/components/quality';
|
import { assembleQualityCanvas } from '../src/components/quality/core/assembler';
|
import { resolveQualityProps } from '../src/components/quality/core/validator';
|
import { createContext } from '../src/components/quality/engine/context';
|
import { renderReport } from '../src/components/quality/engine/render';
|
import { groupedQualityTableDefinition } from '../src/components/quality/inspection/grouped-quality-table';
|
|
const failures: string[] = [];
|
|
// 第 4 节按草稿类型查注册表装配,注册表要显式注册才有内容(设计器在 init 时做)
|
registerAllQualityComponents();
|
|
function expect(label: string, actual: unknown, wanted: unknown): void {
|
const ok = actual === wanted;
|
if (!ok) {
|
failures.push(`${label}:期望 ${JSON.stringify(wanted)},实际 ${JSON.stringify(actual)}`);
|
}
|
// eslint-disable-next-line no-console
|
console.log(`${ok ? ' ok ' : ' FAIL '} ${label} = ${JSON.stringify(actual)}`);
|
}
|
|
function expectTrue(label: string, actual: boolean, detail?: string): void {
|
if (!actual) {
|
failures.push(`${label}${detail ? `:${detail}` : ''}`);
|
}
|
// eslint-disable-next-line no-console
|
console.log(`${actual ? ' ok ' : ' FAIL '} ${label}`);
|
}
|
|
/** 绑定路径与合并属性都必须落在一起,分散到别的列就是错版 */
|
const GROUP_CELL_ATTRS = ' rowspan="{{item.group.span}}" hidden="{{item.group.hidden}}"';
|
|
/** 与组件里的 CELL_BORDER 同值:这里只求浏览器量出来的排版与实际产物同形 */
|
const CELL_STYLE = { border: '1px solid #d9d9d9', padding: '6px 8px' };
|
|
const HEAD_CELL_STYLE = { ...CELL_STYLE, background: '#fafafa', 'font-weight': 600 };
|
|
// ---------------------------------------------------------------- 1. 组件结构(真实 buildContent)
|
|
// eslint-disable-next-line no-console
|
console.log('\n===== 1. 组件结构:7 列、第 2 格带合并属性、重复标记落在 tbody/tr 上 =====');
|
{
|
const content = groupedQualityTableDefinition.buildContent(
|
resolveQualityProps(groupedQualityTableDefinition, {}),
|
);
|
const html = String(content.components);
|
// 数闭合标签:`<th` 会把 `<thead` 也算进去
|
const headCells = html.match(/<\/th>/g) ?? [];
|
const rowCells = html.match(/<td/g) ?? [];
|
|
expect('表头 7 列', headCells.length, 7);
|
expect('行模板 7 列', rowCells.length, 7);
|
expectTrue('表头含「子项」列', html.includes('>子项</th>'));
|
expectTrue('表头含「检测要求」列', html.includes('>检测要求</th>'));
|
|
// 合并属性只能挂在「检验项目」这一格上:挂错格 = 组名跨错列
|
const cells = html.split('<td');
|
const itemNameCell = cells.find((cell) => cell.includes('{{item.itemName}}'));
|
expectTrue('「检验项目」格带 rowspan 与 hidden', Boolean(itemNameCell?.includes(GROUP_CELL_ATTRS)),
|
itemNameCell?.slice(0, 200));
|
expectTrue(
|
'合并属性没有跑到别的格上',
|
cells.filter((cell) => cell.includes('rowspan=')).length === 1,
|
`带 rowspan 的格数=${cells.filter((cell) => cell.includes('rowspan=')).length}`,
|
);
|
expectTrue('「子项」格取 item.group.childName', html.includes('{{item.group.childName}}'));
|
expectTrue('「检测要求」格取 item.requirement', html.includes('{{item.requirement}}'));
|
expectTrue(
|
'重复标记落在 tbody/tr 上',
|
html.includes('data-qc-repeat="inspectionItems"') && html.includes('data-qc-repeat-row="item"'),
|
);
|
}
|
|
// ---------------------------------------------------------------- 2. 真实引擎渲染
|
|
const cell = (text: string, attributes?: Record<string, string>): RenderNode => ({
|
type: 'cell',
|
attributes,
|
style: CELL_STYLE,
|
content: text,
|
});
|
|
/** 表头格:组件产出的是 <th>,GrapesJS 解析成带 tagName 的 cell */
|
const headCell = (text: string): RenderNode => ({
|
type: 'cell',
|
tagName: 'th',
|
style: HEAD_CELL_STYLE,
|
content: text,
|
});
|
|
const row = (cells: RenderNode[], attributes?: Record<string, string>): RenderNode => ({
|
type: 'row',
|
attributes,
|
components: cells,
|
});
|
|
/** 与 buildContent 产物逐格同构的行模板(GrapesJS 把那段 HTML 解析成的树就是这个形状) */
|
function tableSchema(): RenderSchema {
|
const headers = ['序号', '检验项目', '子项', '检测要求', '实测值', '单位', '判定'];
|
return {
|
grapes: {
|
pages: [
|
{
|
frames: [
|
{
|
component: {
|
type: 'table',
|
components: [
|
{
|
type: 'thead',
|
components: [row(headers.map((text) => headCell(text)))],
|
},
|
{
|
type: 'tbody',
|
attributes: { 'data-qc-repeat': 'inspectionItems' },
|
components: [
|
row(
|
[
|
cell('{{index}}'),
|
cell('{{item.itemName}}', {
|
rowspan: '{{item.group.span}}',
|
hidden: '{{item.group.hidden}}',
|
}),
|
cell('{{item.group.childName}}'),
|
cell('{{item.requirement}}'),
|
cell('{{item.actualValue}}'),
|
cell('{{item.unit}}'),
|
cell('{{item.resultText}}'),
|
],
|
{ 'data-qc-repeat-row': 'item' },
|
),
|
],
|
},
|
],
|
},
|
},
|
],
|
},
|
],
|
},
|
} as unknown as RenderSchema;
|
}
|
|
type Item = Partial<ReportContextItem> & { itemName: string };
|
|
/** 组头锚点行:按定义没有实测值,检测要求放组公式 */
|
const anchor = (): Item => ({
|
itemName: '水分',
|
requirement: 'w = (m1 - m0) / m × 100%',
|
actualValue: '',
|
// 每个检验项的字段都要齐(哪怕是空串):字段缺席时绑定的属性会被跳过,合并格反而露出来
|
unit: '',
|
group: { childName: '', span: 3, hidden: '' },
|
});
|
|
/** 组内子项行:自己的名字进「子项」列,合并格让给组头那一行 */
|
const child = (name: string, actual: string, unit: string): Item => ({
|
itemName: name,
|
requirement: '',
|
actualValue: actual,
|
unit,
|
group: { childName: name, span: 1, hidden: 'hidden' },
|
});
|
|
/** 组内子项里真正带规格、能判定的一条 */
|
const judgedChild = (name: string, actual: string, upper: number, lower: number): Item => ({
|
...child(name, actual, '%'),
|
requirement: `≤${upper}`,
|
upperLimit: upper,
|
lowerLimit: lower,
|
});
|
|
/** 独立项:没有分组,一格都不合并 */
|
const standalone = (): Item => ({
|
itemName: '外观',
|
requirement: '无划痕、无异物',
|
actualValue: '无划痕',
|
unit: '',
|
group: { childName: '', span: 1, hidden: '' },
|
});
|
|
function render(items: Item[], label: string) {
|
const result = renderReport({
|
context: createContext({
|
report: { reportNo: 'QC-2026-0001', reportName: '来料检验报告' },
|
inspectionItems: items as ReportContextItem[],
|
}),
|
rules: [],
|
schema: tableSchema(),
|
});
|
writeFileSync(`.qc-conformance/group-table-${label}.html`, result.html, 'utf8');
|
|
const tbody = result.body.slice(result.body.indexOf('<tbody'));
|
const rows = tbody.split('<tr').slice(1);
|
return { result, rows, tds: rows.map((html) => html.split('<td').slice(1)) };
|
}
|
|
// eslint-disable-next-line no-console
|
console.log('\n===== 2. 组头 + 子项 + 独立项混排:合并格、让位格、列数 =====');
|
{
|
// 组头 3 行(锚点 + 2 子项)、独立项 1 行
|
const { result, rows, tds } = render(
|
[anchor(), child('试样质量 m', '50.2', 'g'), judgedChild('水分结果 w', '0.32', 0.5, 0), standalone()],
|
'render',
|
);
|
|
expect('展开 4 行', rows.length, 4);
|
expectTrue('每行都是 7 格', tds.every((cells) => cells.length === 7),
|
tds.map((cells) => cells.length).join(','));
|
// 问题清单里只允许出现「无判定规则」这类如实说明;出现「绑定…取不到值」就说明有绑定没喂上
|
expectTrue('没有取不到值的绑定', result.errors.every((message) => !message.includes('绑定')),
|
JSON.stringify(result.errors));
|
expectTrue('无判定规则照实上报(不静默)',
|
result.errors.filter((message) => message.includes('没有规格上下限也没有判定规则')).length === 3,
|
JSON.stringify(result.errors));
|
|
// 组头行:合并 3 行、自己可见、公式在自己的「检测要求」格
|
expectTrue('组头行 rowspan=3', tds[0]![1]!.includes('rowspan="3"'), tds[0]![1]);
|
expectTrue('组头行不隐藏合并格', !tds[0]![1]!.includes('hidden='), tds[0]![1]);
|
expectTrue('组头「检测要求」放公式', tds[0]![3]!.includes('w = (m1 - m0) / m × 100%'), tds[0]![3]);
|
expectTrue('组头「子项」格为空', tds[0]![2]!.startsWith(' style='), tds[0]![2]);
|
expectTrue('组头序号为 1', tds[0]![0]!.includes('>1<'), tds[0]![0]);
|
|
// 子项行:让出合并格(hidden),合并行数回落 1
|
tds.slice(1, 3).forEach((cells, offset) => {
|
expectTrue(`子项第 ${offset + 1} 行 rowspan=1`, cells[1]!.includes('rowspan="1"'), cells[1]);
|
expectTrue(`子项第 ${offset + 1} 行让出合并格`, cells[1]!.includes('hidden="hidden"'), cells[1]);
|
});
|
expectTrue('子项自己的名字进「子项」列', tds[1]![2]!.includes('试样质量 m'), tds[1]![2]);
|
// 子项的检测要求逐行给:组公式只占组头那一格,子项各有各的规格
|
expectTrue('子项「检测要求」仍是自己的值', tds[2]![3]!.includes('≤0.5'), tds[2]![3]);
|
|
// 独立项:可见、不合并、「子项」列空
|
expectTrue('独立项不隐藏', !tds[3]![1]!.includes('hidden='), tds[3]![1]);
|
expectTrue('独立项「子项」格为空', !tds[3]![2]!.includes('样品') && tds[3]![2]!.includes('</td>'), tds[3]![2]);
|
expectTrue('独立项自己的检测要求在场', tds[3]![3]!.includes('无划痕、无异物'), tds[3]![3]);
|
|
// 判定列:「无判定规则」照样渲染出来,不被分组结构吃掉
|
expectTrue('组头锚点行判为「无判定规则」', tds[0]![6]!.includes('无判定规则'), tds[0]![6]);
|
expectTrue('过程参数判为「无判定规则」', tds[1]![6]!.includes('无判定规则'), tds[1]![6]);
|
expectTrue('带规格的子项判为「合格」', tds[2]![6]!.includes('合格'), tds[2]![6]);
|
|
// 结论不被锚点行与过程参数压下去(它们不计入分母)
|
const report = result.context.report;
|
expect('report.total', report.total, 4);
|
expect('report.passCount', report.passCount, 1);
|
expect('report.passRate(分母只算可判定项)', report.passRate, '100.00%');
|
expect('report.conclusion', report.conclusion, '合格');
|
}
|
|
// eslint-disable-next-line no-console
|
console.log('\n===== 3. 降级:整份数据都没有分组信息,退化成普通表而不是塌掉 =====');
|
{
|
// 真实后端不会产出这种形状(有分组时每一项都带 group),但模板可能被别处喂数据;
|
// 字段缺席时绑定的属性会被跳过 ⇒ 合并格与让位格都不出现,行照常占满 7 列、内容照常渲染
|
const { result, rows, tds } = render(
|
[
|
{ itemName: '外观', requirement: '无划痕、无异物', actualValue: '无划痕', unit: '' },
|
{
|
itemName: '长度',
|
requirement: '10±0.5',
|
actualValue: '10.2',
|
unit: '',
|
upperLimit: 10.5,
|
lowerLimit: 9.5,
|
},
|
],
|
'degrade',
|
);
|
|
expect('展开 2 行', rows.length, 2);
|
expectTrue('每行仍是 7 格', tds.every((cells) => cells.length === 7),
|
tds.map((cells) => cells.length).join(','));
|
expectTrue('合并属性整个不出现', rows.every((html) => !html.includes('rowspan=') && !html.includes('hidden=')),
|
rows.join('\n'));
|
expectTrue('行内容照常渲染', tds[1]![1]!.includes('长度') && tds[1]![3]!.includes('10±0.5'), tds[1]!.join('|'));
|
expectTrue('判定照常算出', tds[1]![6]!.includes('合格'), tds[1]![6]);
|
expectTrue(
|
'缺口以问题清单暴露(不静默)',
|
result.errors.some((message) => message.includes('item.group.span')),
|
JSON.stringify(result.errors),
|
);
|
}
|
|
// ------------------------------------------------- 4. AI 导入走的自定列
|
|
// ------------------------------------------------- 4. AI 导入走的自定列
|
|
// eslint-disable-next-line no-console
|
console.log('\n===== 4. AI 导入走自定列(columns 非空):项目列仍要按组纵向合并 =====');
|
{
|
// 第 1~3 节打的是**默认列**分支(tableSchema 手工同构),而 AI 导入一律填 columns,
|
// 走的是 effectiveColumns 的另一条分支——两条分支各自拼 row,合并属性有没有跟着走,
|
// 默认列绿不代表自定列绿。这里用模型真实产出(模板 33 那份草稿的最后一轮)逐字复现。
|
//
|
// 只验到结构为止:装配产物交给 GrapesJS 解析前后的属性是一字不差的,而「span=N + hidden=''
|
// 会渲成可见的 rowspan=N、span=1 + hidden='hidden' 会让位」这层语义由第 2 节负责——
|
// 那套属性契约与它落在哪一列无关,不必在自定列上重跑一遍渲染。
|
const AI_PROPS = {
|
title: '检 验 结 果',
|
itemsPath: 'inspectionItems',
|
showRequirement: true,
|
showActual: true,
|
showUnit: false,
|
showResult: true,
|
columns:
|
'#项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|' +
|
'标准要求={{item.standardValue}}|结果={{item.actualValue}}|结论={{item.resultText}}',
|
headerSpans: '检验结果^5|结论^1',
|
};
|
|
/** 装配产物的 content 在 node 下是未解析的 HTML 串(GrapesJS 才把它拆成节点树),直接按串断言 */
|
function assembleToHtml(props: Record<string, unknown>): {
|
html: string;
|
skipped: unknown[];
|
problems: string[];
|
} {
|
const assembled = assembleQualityCanvas([{ type: 'GroupedQualityTable', props }]);
|
const page = (assembled.grapes.pages as Array<{ component: { components: unknown[] } }>)[0]!;
|
const node = page.component.components[0] as { components: string };
|
return { html: node.components, skipped: assembled.skipped, problems: assembled.problems };
|
}
|
|
const { html, skipped, problems } = assembleToHtml(AI_PROPS);
|
|
expectTrue('草稿装配无跳过', skipped.length === 0, JSON.stringify(skipped));
|
expectTrue('草稿装配无问题项', problems.length === 0, JSON.stringify(problems));
|
expectTrue('重复标记落在 tbody/tr 上', html.includes('<tbody data-qc-repeat="inspectionItems">') &&
|
html.includes('<tr data-qc-repeat-row="item">'), html.slice(0, 200));
|
|
// 行模板按 columns 出 6 格,且第一格就是「项目」列
|
const cells = html.split('<td').slice(1);
|
expect('行模板 6 格(照 columns 来)', cells.length, 6);
|
expectTrue('第 1 格绑 item.itemName', cells[0]!.includes('{{item.itemName}}'), cells[0]);
|
|
// 「#」那一条才是合并列:组名格自带 span 与 hidden 两个绑定,组内其余行靠它让位。
|
// 这正是「出件时层级合并」的全部机制——少了这行属性,项目列就会每行重复打一遍组名
|
expectTrue(
|
'「#项目」格带 rowspan 与 hidden 绑定',
|
cells[0]!.includes('rowspan="{{item.group.span}}"') && cells[0]!.includes('hidden="{{item.group.hidden}}"'),
|
cells[0],
|
);
|
expect(
|
'合并属性只落在这一格',
|
cells.filter((cell) => cell.includes('rowspan=')).length,
|
1,
|
);
|
|
// 表头:上层「检验结果」横跨 5 列,「结论」自己跨住上下两层 ⇒ 下层不再重复印一遍
|
expectTrue('上层「检验结果」colspan=5', html.includes('colspan="5"') && html.includes('>检验结果</th>'), html.slice(0, 400));
|
expectTrue('「结论」纵向跨两层', /<th rowspan="2"[^>]*>结论<\/th>/.test(html), html.slice(0, 400));
|
const lowerHeads = html.split('<tr>')[2]?.split('<th').length ?? 0;
|
expect('下层表头 5 列(结论已被上层并走)', lowerHeads - 1, 5);
|
|
// 去掉 # 就该不合并 —— 项目列每行都重复打一遍组名,正是「有点丑」的那种版式。
|
// 断言它确实变了,防止 # 被当成装饰、有无都不影响结果
|
const plain = assembleToHtml({ ...AI_PROPS, columns: AI_PROPS.columns.replace('#项目=', '项目=') });
|
expectTrue('去掉 # 后不再有 rowspan / hidden 绑定',
|
!plain.html.includes('rowspan="{{') && !plain.html.includes('hidden="{{'), plain.html.slice(0, 400));
|
expectTrue('去掉 # 后列数与绑定都不变(只少了合并)',
|
plain.html.split('<td').length === 7 && plain.html.includes('{{item.itemName}}'), plain.html.slice(0, 200));
|
}
|
|
// eslint-disable-next-line no-console
|
console.log(
|
failures.length === 0
|
? '\n全部通过:组名合并与子项让位都由数据下发,列数稳定在 7,无判定规则不影响结论。'
|
: `\n有 ${failures.length} 处不符:\n${failures.map((line) => ` - ${line}`).join('\n')}`,
|
);
|
process.exitCode = failures.length === 0 ? 0 : 1;
|