zhangwencui
17 小时以前 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
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
/**
 * 探针:可自定列的检验表(GroupedQualityTable.columns / headerSpans)与可配字段的样品信息
 * (SampleInfo.fields)。
 *
 * 只读,不改代码、不碰数据库。用法:npx tsx .qc-conformance/verify-columns.ts
 *
 * 守三件事:
 * 1. **属性缺席时产物逐字不变**——这是本轮的第一优先级:存量和已发布模板一个字节都不该动。
 *    这里比对的是整段产物字符串的 sha256,而不是「列数还是 7」这种弱断言:
 *    列数不变但 style 顺序变了、属性搬家了,一样会让已发布报告重出时变样。
 * 2. 属性在场时列数、列序、合并属性落点、两级表头跨列都对得上;
 * 3. 写错时 validate 报得出、且报的是能改的那一处(不是笼统一句「格式错误」)。
 *
 * 直接打在真实 buildContent / validate 的产物上:结构一改这里就红,不必等浏览器。
 */
import type { QualityProps } from '../src/components/quality/core/types';
 
import { createHash } from 'node:crypto';
 
import { resolveQualityProps, validateQualityProps } from '../src/components/quality/core/validator';
import { buildQualityComponentCatalog } from '../src/components/quality/core/catalog';
import { parseColumnSpec } from '../src/components/quality/core/column-spec';
import { sampleInfoDefinition } from '../src/components/quality/header/sample-info';
import { groupedQualityTableDefinition } from '../src/components/quality/inspection/grouped-quality-table';
import { registerAllQualityComponents } from '../src/components/quality';
 
const failures: string[] = [];
 
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}`);
}
 
function sha(text: string): string {
  return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
}
 
/** 真实 buildContent 的产物(组件产出的是 HTML 字符串) */
function build(
  definition: typeof groupedQualityTableDefinition | typeof sampleInfoDefinition,
  props?: QualityProps,
): string {
  const resolved = resolveQualityProps(definition, props);
  return String(definition.buildContent(resolved).components);
}
 
function validate(
  definition: typeof groupedQualityTableDefinition | typeof sampleInfoDefinition,
  props?: QualityProps,
): string[] {
  return validateQualityProps(definition, props);
}
 
/** 表头格数:`<th` 会把 `<thead` 也算进去,所以数闭合标签 */
function headCellCount(html: string): number {
  return (html.match(/<\/th>/g) ?? []).length;
}
 
function theadRowCount(html: string): number {
  const thead = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'));
  return (thead.match(/<tr>/g) ?? []).length;
}
 
const MERGE_ATTRS = ' rowspan="{{item.group.span}}" hidden="{{item.group.hidden}}"';
 
// ------------------------------------------------- 1. 缺席 = 逐字不变(第一优先级)
 
// 改这轮之前的产物指纹。列数、style 顺序、合并属性落点,任何一处动了这个 hash 都会变。
// 之所以冻结 hash 而不是冻结整段 HTML:整段太长,diff 时看不出改的是哪一格。
//
// 这两个值不是「照着现在的实现抄一遍」,而是把改前的实现(从工作区里还原出来的旧版本)
// 与改后的实现跑同一批属性、逐字比对之后取下来的:12 组属性组合(默认 / title 空 /
// 各显示开关单独与组合关闭 / 换数据源路径 / 换标题 / 每行 1、3 组 / 关边框)全部 same。
const GROUPED_TABLE_BASELINE = '8e803b882a62ba70';
const SAMPLE_INFO_BASELINE = 'a07da23e176df130';
 
// eslint-disable-next-line no-console
console.log('\n===== 1. 两个新属性缺席:产物必须与改这轮之前逐字一致 =====');
{
  // 默认 props(defaults 里刻意不放 columns / headerSpans / fields)
  const table = build(groupedQualityTableDefinition);
  const sample = build(sampleInfoDefinition);
  expect('分组表:defaults 里没有 columns', 'columns' in groupedQualityTableDefinition.defaults, false);
  expect('分组表:defaults 里没有 headerSpans', 'headerSpans' in groupedQualityTableDefinition.defaults, false);
  expect('样品信息:defaults 里没有 fields', 'fields' in sampleInfoDefinition.defaults, false);
  expect('分组表默认产物指纹', sha(table), GROUPED_TABLE_BASELINE);
  expect('样品信息默认产物指纹', sha(sample), SAMPLE_INFO_BASELINE);
 
  // 属性即使显式传成空串也必须回落:属性面板清空一格是常事,
  // 空串若被当成「0 列」就会渲染出一张没有列的残表
  expect('分组表:columns 传空串仍出默认 7 列', headCellCount(build(groupedQualityTableDefinition, { columns: '' })), 7);
  expect('分组表:headerSpans 传空串仍出单层表头', theadRowCount(build(groupedQualityTableDefinition, { headerSpans: '' })), 1);
  expect('样品信息:fields 传空串仍出默认 6 项', (build(sampleInfoDefinition, { fields: '' }).match(/<td/g) ?? []).length, 12);
 
  // 默认列的合并属性仍只挂在「检验项目」那一格上
  const cells = table.split('<td');
  expectTrue('默认列含「子项」列', table.includes('>子项</th>'));
  expectTrue(
    '默认列:带 rowspan 的格只有 1 个',
    cells.filter((cell) => cell.includes('rowspan=')).length === 1,
  );
  expectTrue('默认列:合并属性在「检验项目」格上', Boolean(cells.find((cell) => cell.includes('{{item.itemName}}'))?.includes(MERGE_ATTRS)));
}
 
// ------------------------------------------------- 2. columns 在场:列数、列序、合并落点
 
// eslint-disable-next-line no-console
console.log('\n===== 2. columns 在场:整表按它来,显示开关不再生效 =====');
{
  const custom =
    '序号={{index}}|#检验项目={{item.itemName}}|子项={{item.group.childName}}|' +
    '检测方法={{item.checkMethod}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}|' +
    '单位={{item.unit}}|判定={{item.resultText}}';
  const html = build(groupedQualityTableDefinition, { columns: custom });
 
  expect('表头 8 列', headCellCount(html), 8);
  expect('行模板 8 格', (html.match(/<td/g) ?? []).length, 8);
  expectTrue('新增的「检测方法」列表头在场', html.includes('>检测方法</th>'));
  expectTrue('「检测方法」列取 item.checkMethod', html.includes('{{item.checkMethod}}'));
 
  // 列序 = 串里的顺序:检测方法必须夹在「子项」与「检测要求」之间
  const heads = [...html.matchAll(/<th[^>]*>([^<]*)<\/th>/g)].map((match) => match[1]);
  expect('表头顺序', heads.join(','), '序号,检验项目,子项,检测方法,检测要求,实测值,单位,判定');
 
  // 合并属性跟着 `#` 走:加了一列之后它仍必须落在「检验项目」那一格,且只有一格
  const cells = html.split('<td');
  expectTrue('合并属性落在 # 标记的那一列', Boolean(cells.find((cell) => cell.includes('{{item.itemName}}'))?.includes(MERGE_ATTRS)));
  expect(
    '带合并属性的格数',
    cells.filter((cell) => cell.includes('rowspan=')).length,
    1,
  );
 
  // 显示开关被 columns 覆盖:把序号关掉、又自己写回序号列,就得出序号(两面打架时以 columns 为准)
  const withToggleOff = build(groupedQualityTableDefinition, { columns: custom, showIndex: false });
  expect('关掉「显示序号」也不影响自定义列', headCellCount(withToggleOff), 8);
 
  // 默认列 7 列、这里 8 列:若解析被忽略,一定是 7
  expectTrue('确实没有回落到默认列', headCellCount(html) !== 7);
}
 
// ------------------------------------------------- 3. headerSpans 在场:两级表头
 
// eslint-disable-next-line no-console
console.log('\n===== 3. headerSpans 在场:上层表头 + 下层列名 =====');
{
  const html = build(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目={{item.itemName}}|子项={{item.group.childName}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}|单位={{item.unit}}|判定={{item.resultText}}',
    headerSpans: '检验结果^5|结论^2',
  });
 
  expect('thead 变成两行', theadRowCount(html), 2);
  expectTrue('上层第一格跨 5 列', html.includes('colspan="5"'));
  expectTrue('上层第二格跨 2 列', html.includes('colspan="2"'));
  expectTrue('上层表头文字在场', html.includes('>检验结果<') && html.includes('>结论<'));
 
  // 上层 2 格、下层 7 格:上层只加跨列标题,绝不能把下层的列名顶掉
  const theadRows = html
    .slice(html.indexOf('<thead'), html.indexOf('</thead>'))
    .split('<tr>')
    .slice(1);
  expect('上层表头 2 格', (theadRows[0]?.match(/<\/th>/g) ?? []).length, 2);
  expect('下层表头 7 格', (theadRows[1]?.match(/<\/th>/g) ?? []).length, 7);
 
  // 只有一层时不要凭空多出一行
  expect('没填 headerSpans 仍是单层', theadRowCount(build(groupedQualityTableDefinition)), 1);
 
  // 跨列数写不出 ^N 时按 1 列算,靠总数兜底(parser 的既有约定)
  const implicit = build(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目={{item.itemName}}|子项={{item.group.childName}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}|单位={{item.unit}}|判定={{item.resultText}}',
    headerSpans: '检验结果|结论',
  });
  expect('七段各按 1 列算,合计 2 ≠ 7 ⇒ 整层丢弃退回单层', theadRowCount(implicit), 1);
}
 
// --------------------------------- 3b. 纵向合并格:一列自己既是列名又是上格
 
// eslint-disable-next-line no-console
console.log('\n===== 3b. 纵向合并格(结论这类列)合成跨两行的一格 =====');
{
  // 百事原件里最右一列「结论」是自己占满上下两层表头的一格(提取文本里下层那格写着「↑同上」)。
  // 真模型一直照写 `结论^1`,若不认这个形态,「结论」二字会在上下两层各印一次。
  const vertical = build(groupedQualityTableDefinition, {
    columns:
      '检验项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|标准要求={{item.standardValue}}|结果={{item.actualValue}}|结论={{item.resultText}}',
    headerSpans: '检验结果^5|结论^1',
  });
  expectTrue('纵向合并格出 rowspan="2"', vertical.includes('rowspan="2"'));
  expectTrue('不再出一格跨一列的上格', !vertical.includes('colspan="1"'));
  expect('「结论」全文只印一次', (vertical.match(/结论/g) ?? []).length, 1);
  expect('上层 2 格 + 下层 5 格 = 7', headCellCount(vertical), 7);
  expect('仍然是两行表头', theadRowCount(vertical), 2);
 
  // 判据是「标题与该列列名一字不差」,不是「排在最后」:不同字就老老实实按分组跨列走
  const notVertical = build(groupedQualityTableDefinition, {
    columns: '检验项目={{item.itemName}}|判定={{item.resultText}}',
    headerSpans: '检验结果^1|结论^1',
  });
  expectTrue('上格与列名不同字 ⇒ 不出 rowspan', !notVertical.includes('rowspan="2"'));
  expect('上格与列名不同字 ⇒ 两层各 2 格', headCellCount(notVertical), 4);
}
 
// ------------------------------------------------- 3c. 自定列不许把表格撑出纸张
 
// eslint-disable-next-line no-console
console.log('\n===== 3c. 自定列的单元格允许长串折行(否则整表溢出纸张)=====');
{
  // 自定列拿不到任何宽度声明,列宽只能由最小内容宽度倒推;而值是 {{item.standardValue}}
  // 这类不含空格的长串,最小内容宽度就是整串长度。实测 6 列时整表 810px,
  // 而 A4 正文宽只有 672px:表格溢出 138px,最右一列被挤成 29px 的竖条
  // (设计器里就是「结论」两个字竖着排在纸张外面)。加 overflow-wrap:anywhere 后整表 672px。
  const custom = build(groupedQualityTableDefinition, {
    columns:
      '#项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|' +
      '标准要求={{item.standardValue}}|结果={{item.actualValue}}|结论={{item.resultText}}',
    headerSpans: '检验结果^5|结论^1',
  });
  const customCells = custom.split('<td').slice(1);
  expect('自定列出 6 个单元格', customCells.length, 6);
  expect(
    '自定列的格都带 overflow-wrap:anywhere',
    customCells.filter((cell) => cell.includes('overflow-wrap:anywhere')).length,
    6,
  );
 
  // 默认列反过来:它自带手工调过的宽度,再叠一条折行会让没写宽度的「检验项目」「子项」
  // 被挤到 30 余像素、行高从 59px 涨到 283px——实测过,不加比加好
  const fallback = build(groupedQualityTableDefinition);
  expectTrue('默认列的格不带 overflow-wrap', !fallback.includes('overflow-wrap'));
}
 
// ------------------------------------------------- 4. 写错时报得出、报得准
 
// eslint-disable-next-line no-console
console.log('\n===== 4. 写错时 validate 报的是能改的那一处 =====');
{
  const badSum = validate(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目={{item.itemName}}|子项={{item.group.childName}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}',
    headerSpans: '检验结果^5|结论^2',
  });
  expect('跨列数不匹配报 1 条', badSum.length, 1);
  expectTrue(
    '文案同时给出合计与实际列数',
    badSum[0]?.includes('合计 7') && badSum[0]?.includes('实际列数为 5'),
    badSum[0],
  );
  expectTrue('文案给出可执行动作', Boolean(badSum[0]?.includes('^')), badSum[0]);
 
  const missingEqual = validate(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目',
  });
  expect('缺 = 报 1 条', missingEqual.length, 1);
  expectTrue('文案按段号定位', Boolean(missingEqual[0]?.includes('第 2 段')), missingEqual[0]);
 
  const emptyExpr = validate(groupedQualityTableDefinition, { columns: '序号={{index}}|检测方法=' });
  expectTrue('空的绑定表达式被拦下', emptyExpr.some((line) => line.includes('第 2 段')), JSON.stringify(emptyExpr));
 
  // 列定义有错时不再叠加跨列数校验:那时列数不可信,报了只会把人往错的方向带
  const both = validate(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目',
    headerSpans: '检验结果^5',
  });
  expect('列定义错 + 表头跨列也错 ⇒ 只报列定义', both.length, 1);
  expectTrue('报的是列定义那条', Boolean(both[0]?.includes('第 2 段')), both[0]);
 
  // 用默认列 + 两层表头:跨列数按默认的 7 列校验
  const defaultOk = validate(groupedQualityTableDefinition, { headerSpans: '检验结果^5|结论^2' });
  expect('默认 7 列 + 检验结果^5|结论^2 通过', defaultOk.length, 0);
 
  // 隐藏了检测要求却留着判定:只有在用默认列时才算数(填了 columns 整表按 columns 来)
  const toggleClash = validate(groupedQualityTableDefinition, { showRequirement: false });
  expectTrue('默认列下才报「隐藏检测要求却保留判定」', toggleClash.length === 1, JSON.stringify(toggleClash));
  const toggleClashCustom = validate(groupedQualityTableDefinition, {
    showRequirement: false,
    columns: '序号={{index}}|判定={{item.resultText}}',
  });
  expect('填了 columns 就不再报那条误报', toggleClashCustom.length, 0);
}
 
// ------------------------------------------------- 5. 样品信息字段可配
 
// eslint-disable-next-line no-console
console.log('\n===== 5. SampleInfo.fields =====');
{
  const html = build(sampleInfoDefinition, {
    fields:
      '样品编号={{report.sampleNo}}|产品名称={{report.productName}}|规格={{report.spec}}|' +
      '生产日期={{report.produceDate}}|批号={{report.batchNo}}|土豆品种={{report.potatoVariety}}|有效日期={{report.expireDate}}',
    pairsPerRow: 2,
  });
  expect('7 项 × 2 格 = 14 个 td', (html.match(/<td/g) ?? []).length, 14);
  expect('7 项每行 2 组 ⇒ 4 行', (html.match(/<tr>/g) ?? []).length, 4);
  expectTrue('自定义字段名在场', html.includes('>土豆品种<'));
  expectTrue('自定义绑定在场', html.includes('{{report.potatoVariety}}'));
 
  // 每行放几组仍由 pairsPerRow 控制
  const oneRow = build(sampleInfoDefinition, { fields: 'A={{report.a}}|B={{report.b}}|C={{report.c}}', pairsPerRow: 3 });
  expect('pairsPerRow=3 ⇒ 1 行', (oneRow.match(/<tr>/g) ?? []).length, 1);
  expect('pairsPerRow=3 ⇒ 6 格', (oneRow.match(/<td/g) ?? []).length, 6);
 
  // 末行凑不满一组时,最后一格要横向吃掉余下的列。
  // 不补的话右侧秃出几个只有边框的空格子——字段数不是「每行组数」整数倍时必然出现,
  // 而 AI 导入按原件字段填 fields,奇偶根本不由我们定(原件 5 项、每行 2 组是最典型的一种)。
  const ragged = build(sampleInfoDefinition, {
    fields:
      '产品名称={{report.productName}}|规格={{report.spec}}|批号={{report.batchNo}}|' +
      '检验员={{report.inspector}}|检验日期={{report.inspectDate}}',
    pairsPerRow: 2,
  });
  expect('5 项 ÷ 2 ⇒ 3 行', (ragged.match(/<tr>/g) ?? []).length, 3);
  expect('补齐不增减格数(仍是 5 对 = 10 格)', (ragged.match(/<td/g) ?? []).length, 10);
  expect('只多出 1 个 colspan', (ragged.match(/colspan=/g) ?? []).length, 1);
  expectTrue('补齐落在末行最后一格,跨 3 列(2 列空位 + 自己)', ragged.includes('colspan="3"'), ragged.slice(-300));
  const raggedTail = ragged.split('<tr>').pop() ?? '';
  const tailCells = raggedTail.split('<td').slice(1);
  expect('末行还是 2 格(字段名 + 值)', tailCells.length, 2);
  expectTrue(
    '补的是「值」格而不是「字段名」格:补到字段名上这一列的宽度就跟着变,各行左边缘会错开',
    tailCells[0]?.includes('width:90px') && !tailCells[0]?.includes('colspan'),
    tailCells[0],
  );
  expectTrue(
    '末行仍是「检验日期 + 它的值」这一对',
    raggedTail.includes('>检验日期<') && raggedTail.includes('{{report.inspectDate}}'),
    raggedTail,
  );
 
  // 整行凑满时一格都不许补:默认字段正是 6 项(÷2 整行),多补一格就会动到上面冻结的逐字指纹
  expectTrue('默认 6 项 ÷ 2 全是整行,无 colspan', !build(sampleInfoDefinition).includes('colspan='));
  expectTrue(
    'pairsPerRow=1 时每行自成一组,永远不补',
    !build(sampleInfoDefinition, { pairsPerRow: 1 }).includes('colspan='),
  );
  const raggedThree = build(sampleInfoDefinition, {
    fields: 'A={{report.a}}|B={{report.b}}|C={{report.c}}|D={{report.d}}',
    pairsPerRow: 3,
  });
  expect('4 项 ÷ 3 ⇒ 末行 1 项,跨 5 列补齐(3 列空位 + 自己)', (raggedThree.match(/colspan="\d+"/g) ?? []).join(','), 'colspan="5"');
 
  // 合并标记在样品信息里没有意义,必须明确拒绝而不是静默丢掉
  const mergeRejected = validate(sampleInfoDefinition, { fields: '#样品编号={{report.sampleNo}}' });
  expect('字段名带 # 报 1 条', mergeRejected.length, 1);
  expectTrue('文案说明 # 是分组表的用法', Boolean(mergeRejected[0]?.includes('分组检验项表')), mergeRejected[0]);
  expect('没有 # 则通过', validate(sampleInfoDefinition, { fields: '样品编号={{report.sampleNo}}' }).length, 0);
}
 
// ------------------------------------------------- 6. 注入:列标题/表达式先转义再进 HTML
 
// eslint-disable-next-line no-console
console.log('\n===== 6. 手写的列标题与字段名不能成为注入点 =====');
{
  // 注意标题里不能带 `=`:`=` 是「标题=表达式」的分隔符,带 `=` 的标题在语法上就是非法的
  // (见 parseColumnSpec 的约定),所以这里用不含 `=` 的标签来验转义
  const table = build(groupedQualityTableDefinition, {
    columns: '检验项目={{item.itemName}}|"><script>alert(1)</script>={{item.actualValue}}',
  });
  expectTrue(
    '列标题里的标签被转义',
    table.includes('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;'),
    table.slice(0, 400),
  );
  expectTrue('产物里没有活的 script', !table.includes('<script'), table.slice(0, 400));
 
  const sample = build(sampleInfoDefinition, { fields: '"><script>alert(1)</script>={{report.sampleNo}}' });
  expectTrue('字段名里的引号与标签被转义', sample.includes('&quot;&gt;&lt;script&gt;'), sample.slice(0, 400));
  expectTrue('没有逃出属性外的 script', !sample.includes('<script>'), sample.slice(0, 400));
}
 
// ------------------------------------------------- 7. 解析器边界(两个组件共用的那一份)
 
// eslint-disable-next-line no-console
console.log('\n===== 7. 共用解析器:空段跳过、= 只切第一个、^ 从最后切 =====');
{
  expect('首尾多写 | 不报错', parseColumnSpec('|A={{a}}|B={{b}}|').errors.length, 0);
  expect('首尾多写 | 出 2 列', parseColumnSpec('|A={{a}}|B={{b}}|').items.length, 2);
  expect('空串出 0 列', parseColumnSpec('   ').items.length, 0);
  expect('undefined 出 0 列', parseColumnSpec(undefined).items.length, 0);
  // 表达式里再出现 = 时不能被切成第三段
  expect('= 只切第一个', parseColumnSpec('A=1=2').items[0]?.expr, '1=2');
  expect('# 被剥掉并记为合并列', parseColumnSpec('#检验项目={{item.itemName}}').items[0]?.label, '检验项目');
  expect('没写 # 就不是合并列', parseColumnSpec('检验项目={{item.itemName}}').items[0]?.merge, false);
}
 
// ------------------------------------------------- 8. aiHint / 积木清单:模型得知道能填这两个属性
 
// eslint-disable-next-line no-console
console.log('\n===== 8. aiHint 要说「列不一样就用 columns」,而不是「换组件」 =====');
{
  const hint = groupedQualityTableDefinition.aiHint ?? '';
  expectTrue('hint 点名了 columns', hint.includes('columns'), hint);
  expectTrue('hint 点名了 headerSpans', hint.includes('headerSpans'), hint);
  expectTrue(
    'hint 说清「既有列名又是上格」的列怎么报',
    hint.includes('↑同上') && hint.includes('跨两行'),
    hint,
  );
  // 这一条是本轮最要紧的措辞:上一轮选型不稳的根因就是提示词里多了一处指向别处的描述,
  // hint 若暗示「列不一致 ⇒ 换个组件」,等于把刚修好的稳定性再推回去
  expectTrue('hint 明确「不要改用别的组件」', hint.includes('不要改用别的组件'), hint);
  expectTrue('hint 仍保留「平铺一层时用 QualityTable」这条边界', hint.includes('QualityTable'), hint);
 
  // 清单是 aiHint 与新属性进提示词的唯一出口:不在这里出现,模型就不可能填出来
  registerAllQualityComponents();
  const entry = buildQualityComponentCatalog().find((item) => item.type === 'GroupedQualityTable');
  const keys = (entry?.fields ?? []).map((field) => field.key).join(',');
  expectTrue('columns 随 propertySchema 自动进清单', keys.includes('columns'), keys);
  expectTrue('headerSpans 随 propertySchema 自动进清单', keys.includes('headerSpans'), keys);
  expectTrue('columns 声明为 text(属性面板出多行框)',
    (entry?.fields ?? []).find((field) => field.key === 'columns')?.type === 'text', keys);
  expectTrue('columns 不可绑定(手写整串,不走选数据字段)',
    !(entry?.fields ?? []).find((field) => field.key === 'columns')?.bindable, keys);
  expectTrue('清单带上了 hint', Boolean(entry?.hint?.includes('columns')));
 
  const sample = buildQualityComponentCatalog().find((item) => item.type === 'SampleInfo');
  expectTrue('SampleInfo 的 fields 进了清单',
    (sample?.fields ?? []).some((field) => field.key === 'fields'));
}
 
// eslint-disable-next-line no-console
console.log(
  failures.length === 0
    ? '\n全部通过:缺席时逐字不变,在场时列数/列序/合并/跨列都对,写错能定位到段,手写文本已转义,清单里带得到新属性。'
    : `\n有 ${failures.length} 处不符:\n${failures.map((line) => `  - ${line}`).join('\n')}`,
);
process.exitCode = failures.length === 0 ? 0 : 1;