/**
|
* 对拍脚本: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('<br>'),
|
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);
|