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
/**
 * 对拍脚本:AI 草稿 → 画布装配器(assembleQualityCanvas)的往返一致性。
 *
 * 核心断言是「装配器与 collectSchema 互为逆运算」:
 * 装配写进画布的属性,经 attributesToProps 还原后,必须与 resolveQualityProps 的结果完全相等。
 * 不相等就意味着「AI 生成 → 保存 → 再打开」会掉属性或改变类型。
 *
 * 用法:npx tsx .qc-conformance/assemble-ts.ts
 */
import type {
  QualityComponentDefinition,
  QualityProps,
} from '../src/components/quality/core/types';
 
import {
  assembleQualityCanvas,
  attributesToProps,
  buildQualityComponentCatalog,
  getQualityComponent,
  listQualityComponents,
  registerAllQualityComponents,
  resolveQualityProps,
} from '../src/components/quality';
import { QUALITY_TYPE_ATTR } from '../src/components/quality/core/attrs';
 
// 注册表要显式注册才有内容(设计器在 init 时做)。这里补上,模拟设计器已初始化的状态。
registerAllQualityComponents();
 
let failures = 0;
 
function check(label: string, condition: boolean, detail?: string): void {
  if (condition) {
    // eslint-disable-next-line no-console
    console.log(`  ✓ ${label}`);
    return;
  }
  failures += 1;
  // eslint-disable-next-line no-console
  console.log(`  ✗ ${label}${detail ? `\n      ${detail}` : ''}`);
}
 
/** 只比较声明过的字段;undefined 视同缺席(propsToAttributes 会跳过它) */
function comparable(
  definition: QualityComponentDefinition,
  props: QualityProps,
): string {
  return definition.propertySchema
    .filter((field) => props[field.key] !== undefined)
    .map((field) => `${field.key}=${JSON.stringify(props[field.key])}`)
    .sort()
    .join('|');
}
 
interface FakeResult {
  grapes: Record<string, unknown>;
}
 
function nodesOf(result: FakeResult): Record<string, unknown>[] {
  const pages = result.grapes.pages as {
    component: { components: Record<string, unknown>[] };
  }[];
  return pages[0]!.component.components;
}
 
function attributesOf(node: Record<string, unknown>): Record<string, string> {
  return node.attributes as Record<string, string>;
}
 
// ---------------------------------------------------------------- 1. 全组件装配
 
const definitions = listQualityComponents();
 
/** 给 columns 填的合法列定义;headerSpans 的跨列数合计要与它的列数对齐 */
const FILLER_COLUMNS = '项目={{item.itemName}}|判定={{item.resultText}}';
 
/** 每个字段都塞一个「与默认值不同」的合法值,确保测的是转换而不是默认值 */
const drafts = definitions.map((definition, index) => {
  const props: Record<string, unknown> = {};
  definition.propertySchema.forEach((field) => {
    if (field.type === 'boolean') {
      props[field.key] = false;
    } else if (field.type === 'number') {
      props[field.key] = field.key === 'level' ? 3 : 1;
    } else if (field.type === 'enum') {
      const option = field.options?.[field.options.length - 1];
      if (option) {
        props[field.key] = option.value;
      }
    } else if (field.key === 'columns') {
      // 列定义是有语法的文本属性(列标题=绑定表达式):塞 `值N-columns` 这种自由文本
      // 只会让校验报「缺少 =」,把「无问题项」这条断言假红
      props[field.key] = FILLER_COLUMNS;
    } else if (field.key === 'fields') {
      // 同上,语法是 字段名=绑定表达式
      props[field.key] = '样品编号={{report.sampleNo}}';
    } else if (field.key === 'headerSpans') {
      // 段尾 ^ 后的数字之和必须等于上面 columns 的列数,随意填会被校验挡下
      props[field.key] = `合计^${FILLER_COLUMNS.split('|').length}`;
    } else {
      props[field.key] = `值${index}-${field.key}`;
    }
  });
  return { type: definition.type, props };
});
 
const all = assembleQualityCanvas(drafts);
const nodes = nodesOf(all);
 
// eslint-disable-next-line no-console
console.log('1. 全组件装配');
check(
  `全部内置组件装配成功(草稿 ${drafts.length} / 节点 ${nodes.length})`,
  nodes.length === definitions.length && definitions.length === 13,
);
check(`无跳过项`, all.skipped.length === 0, JSON.stringify(all.skipped));
check(`无问题项`, all.problems.length === 0, JSON.stringify(all.problems));
 
// ---------------------------------------------------------------- 2. collectSchema 的命中条件
 
// eslint-disable-next-line no-console
console.log('2. collectSchema 的命中条件');
nodes.forEach((node, index) => {
  const attributes = attributesOf(node);
  const label = `第 ${index + 1} 个节点(${definitions[index]!.type})`;
  check(`${label} 带 ${QUALITY_TYPE_ATTR}`, Boolean(attributes[QUALITY_TYPE_ATTR]));
  // data-qc-repeat / repeat-row 由 buildContent 生成在嵌套的 tbody/tr 上;
  // 一旦落到顶层节点,属性面板与渲染期的重复展开都会串味
  check(
    `${label} 顶层不含 repeat 属性`,
    !('data-qc-repeat' in attributes) && !('data-qc-repeat-row' in attributes),
  );
});
 
// ---------------------------------------------------------------- 3. 往返一致
 
// eslint-disable-next-line no-console
console.log('3. 往返一致(attributesToProps ∘ assemble == resolveQualityProps)');
nodes.forEach((node, index) => {
  const definition = definitions[index]!;
  const attributes = attributesOf(node);
  const fromCanvas = comparable(definition, attributesToProps(definition, attributes));
  const intended = comparable(
    definition,
    resolveQualityProps(definition, drafts[index]!.props as QualityProps),
  );
  check(`${definition.type} 往返无损`, fromCanvas === intended, `画布侧[${fromCanvas}] ≠ 期望[${intended}]`);
});
 
// ---------------------------------------------------------------- 4. 属性确实写进了画布
 
// eslint-disable-next-line no-console
console.log('4. 属性确实写进了画布(防止第 3 步靠两边同时取默认值而假绿)');
drafts.forEach((draft, index) => {
  const definition = definitions[index]!;
  const attributes = attributesOf(nodes[index]!);
  const keys = Object.keys(draft.props);
  const missing = keys.filter((key) => !(`data-qc-${key}` in attributes));
  check(`${definition.type}(${keys.length} 个字段)`, missing.length === 0, `未写入:${missing.join(', ')}`);
});
 
// ---------------------------------------------------------------- 5. 类型稳定往返
 
// eslint-disable-next-line no-console
console.log('5. boolean / number / enum 的字符串往返');
const boolDefinition = getQualityComponent('QualityTable')!;
const boolResult = assembleQualityCanvas([
  { type: 'QualityTable', props: { showIndex: false, showUnit: true, title: '检验项目' } },
]);
{
  const attributes = attributesOf(nodesOf(boolResult)[0]!);
  check('boolean false → 字符串 "false"', attributes['data-qc-showIndex'] === 'false', attributes['data-qc-showIndex']);
  check('boolean true → 字符串 "true"', attributes['data-qc-showUnit'] === 'true');
  const back = attributesToProps(boolDefinition, attributes);
  check('boolean false 还原为 false', back.showIndex === false, String(back.showIndex));
  check('boolean true 还原为 true', back.showUnit === true, String(back.showUnit));
}
 
const headingDefinition = getQualityComponent('Heading')!;
const numResult = assembleQualityCanvas([
  { type: 'Heading', props: { level: 3, align: 'left', text: '报告标题' } },
]);
{
  const attributes = attributesOf(nodesOf(numResult)[0]!);
  const back = attributesToProps(headingDefinition, attributes);
  check('enum(字符串值)原样往返', back.align === 'left', String(back.align));
  // level 是「取值为数字的枚举」,不是 number 字段:往返只能保证字面量一致,
  // 类型一律收敛成字符串,校验与 buildContent 都按 String()/Number() 比较,不影响渲染
  check('enum(数字值)往返为等价字面量', String(back.level) === '3', `${typeof back.level} ${back.level}`);
}
 
const dividerDefinition = getQualityComponent('Divider')!;
const numberResult = assembleQualityCanvas([{ type: 'Divider', props: { thickness: 3 } }]);
{
  const attributes = attributesOf(nodesOf(numberResult)[0]!);
  check('number 3 → 字符串 "3"', attributes['data-qc-thickness'] === '3', attributes['data-qc-thickness']);
  const back = attributesToProps(dividerDefinition, attributes);
  check(
    'number 3 还原为数字 3',
    back.thickness === 3 && typeof back.thickness === 'number',
    `${typeof back.thickness} ${back.thickness}`,
  );
}
 
// ---------------------------------------------------------------- 6. 坏数据隔离
 
// eslint-disable-next-line no-console
console.log('6. 未知类型与结构化属性');
const messy = assembleQualityCanvas([
  { type: 'QualityTable', props: { title: '检验项目' } },
  { type: 'CustomHtml', props: { html: '<script>alert(1)</script>' } },
  { type: 'Heading', props: { text: { nested: true }, level: [1, 2] } },
  { type: '', props: {} },
]);
{
  const messyNodes = nodesOf(messy);
  check('坏元素不影响其余:4 项中装进 2 项', messyNodes.length === 2, `实际 ${messyNodes.length}`);
  check(
    '未知类型与空类型进 skipped',
    messy.skipped.length === 2,
    JSON.stringify(messy.skipped.map((item) => item.type)),
  );
  check(
    '未注册类型没有产出任何节点',
    !messyNodes.some((node) => attributesOf(node)[QUALITY_TYPE_ATTR] === 'CustomHtml'),
  );
  check('script 内容没有渗进画布数据', !JSON.stringify(messy.grapes).includes('alert(1)'));
  check('结构化属性各产一条 problem', messy.problems.length === 2, JSON.stringify(messy.problems));
  const headingAttrs = attributesOf(messyNodes[1]!);
  check('对象属性回落到默认值', headingAttrs['data-qc-text'] === '报告标题', headingAttrs['data-qc-text']);
  check('数组属性回落到默认值', headingAttrs['data-qc-level'] === '2', headingAttrs['data-qc-level']);
  check(
    'problem 文案指明组件与字段',
    messy.problems.every((message) => message.includes('标题') && message.includes('结构化数据')),
    JSON.stringify(messy.problems),
  );
}
 
// ---------------------------------------------------------------- 7. 必填缺失可被提示
 
// eslint-disable-next-line no-console
console.log('7. 必填缺失');
// itemsPath 是 QualityTable 唯一的必填字段(title 不是),用它验证校验确实被触发
const missing = assembleQualityCanvas([{ type: 'QualityTable', props: { itemsPath: '' } }]);
check('必填缺失产 problem', missing.problems.length > 0, JSON.stringify(missing.problems));
check(
  'problem 文案指明组件与字段',
  missing.problems.some((message) => message.includes('检验') && message.includes('数据源')),
  JSON.stringify(missing.problems),
);
check('组件本身仍装进画布', nodesOf(missing).length === 1);
const nonRequired = assembleQualityCanvas([{ type: 'QualityTable', props: { title: '' } }]);
check('非必填留空不产 problem', nonRequired.problems.length === 0, JSON.stringify(nonRequired.problems));
 
// ---------------------------------------------------------------- 8. 空输入
 
// eslint-disable-next-line no-console
console.log('8. 空输入');
const empty = assembleQualityCanvas([]);
check('空输入仍返回非空 pages', (empty.grapes.pages as unknown[]).length === 1);
check('空输入节点为空', nodesOf(empty).length === 0);
check('undefined 输入不抛错', nodesOf(assembleQualityCanvas()).length === 0);
 
// ---------------------------------------------------------------- 9. 积木清单
 
// eslint-disable-next-line no-console
console.log('9. 积木清单');
const catalog = buildQualityComponentCatalog();
check('清单含全部 13 个组件', catalog.length === 13, `实际 ${catalog.length}`);
check(
  '清单项只含 category/fields/hint/label/type',
  catalog.every((item) => Object.keys(item).sort().join(',') === 'category,fields,hint,label,type'),
  JSON.stringify(Object.keys(catalog[0]!)),
);
// 选型指引是 AI 导入质量的地基:少写一条,模型就在那个组件上退回瞎猜
const missingHint = listQualityComponents()
  .filter((definition) => !definition.aiHint?.trim())
  .map((definition) => definition.type);
check(
  '每个组件都写了非空 aiHint',
  missingHint.length === 0,
  `缺 hint 的组件:${missingHint.join(',') || '(无)'}`,
);
const serialized = JSON.stringify(catalog);
check(
  '无 buildContent / icon / validate / defaults 泄漏',
  !serialized.includes('buildContent') &&
    !serialized.includes('"icon"') &&
    !serialized.includes('validate') &&
    !serialized.includes('defaults'),
);
const tableEntry = catalog.find((item) => item.type === 'QualityTable')!;
const itemsPathField = tableEntry.fields.find((field) => field.key === 'itemsPath')!;
check(
  'itemsPath 标记为必填且可绑定',
  itemsPathField.required === true && itemsPathField.bindable === true,
);
check('非枚举字段不带 enumOptions', itemsPathField.enumOptions === undefined);
const alignField = catalog
  .find((item) => item.type === 'Heading')!
  .fields.find((field) => field.key === 'align')!;
check(
  '枚举项同时给出 label 与 value',
  Boolean(alignField.enumOptions?.every((option) => option.label && option.value !== undefined)),
  JSON.stringify(alignField.enumOptions),
);
// 清单是派生视图,条数与注册表一致即可;类型必须都能反查回注册表
check(
  '清单里每个 type 都能反查回注册表',
  catalog.every((item) => Boolean(getQualityComponent(item.type))),
);
 
// ---------------------------------------------------------------- 10. 多行落款
 
// eslint-disable-next-line no-console
console.log('10. 多行落款不被折成一行');
// AI 会把原件页脚与落款签署行合并成一段多行文本。只转义不折行的话,
// 渲染时换行符会被 HTML 的空白折叠吃掉,页脚与制造商信息糊成一片。
const MULTILINE = '检验员:__________\n制造商:新疆大罗素马铃薯制品有限公司';
[
  { type: 'ReportFooter', props: { text: MULTILINE } },
  { type: 'Text', props: { text: MULTILINE } },
].forEach((draft) => {
  const node = nodesOf(assembleQualityCanvas([draft]))[0]!;
  const attributes = attributesOf(node);
  const content = String(node.content ?? '');
  check(
    `${draft.type} 的 content 把换行折成 <br>`,
    content.includes('<br>') && !content.includes('&lt;br&gt;'),
    content,
  );
  // 属性里保留原始换行:属性面板与绑定都按纯文本处理,折行只发生在渲染产物这一侧
  check(`${draft.type} 的属性保留原始换行`, attributes['data-qc-text'] === MULTILINE, attributes['data-qc-text']);
});
 
// eslint-disable-next-line no-console
console.log(failures === 0 ? '\n全部通过' : `\n${failures} 项失败`);
process.exit(failures === 0 ? 0 : 1);