zhangwencui
14 小时以前 ceeeb4f3315fbd4e4ca3e91efffcee0a54bc22ee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
/**
 * 探针:分组检验项表(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;