/**
 * 探针：可自定列的检验表（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;
