report-statistics.test.mjs 5.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import { STATISTICS_COLUMNS, statisticsRows, validateReportStatistics } from '../skills/ksf-workshop-runtime/scripts/report-statistics.mjs';
  7. import { renderReportHtml } from '../skills/ksf-workshop-runtime/scripts/render-report-html.mjs';
  8. import { prepareHtmlReport, saveAndVerifyReport } from '../skills/ksf-workshop-runtime/scripts/save-report.mjs';
  9. import { statisticsRows as boardStatisticsRows } from '../src/core/report-statistics.js';
  10. const section = () => ({ id: 'statistics-summary', kind: 'table', data: { cols: [...STATISTICS_COLUMNS], rows: [
  11. ['支持A的记录', 4, 10, '合成测试|逐条判定10条有效记录;同主题去重;非真实资料', '部分支持,不等于结论成立;反证仍需比较', '先核对照样品,不自动改变产品', 'e'],
  12. ] } });
  13. const task = () => ({ sessionId: 'test-person', taskKey: 'statistics-test', version: 1, artifactType: 'voc_report', title: '隔离合成统计测试',
  14. report: { executive: ['合成测试,不是研究结果'], delivery: { schema: 'ksf.workshop-delivery.v1', moduleId: 'D2-02', kind: 'research', contentStatus: 'draft', outputs: [] },
  15. chapters: [{ title: '全体相关材料统计', sections: [section()] }], evidence: { e: { claim: '测试支持材料', quotes: [{ text: '合成原话,只用于测试', ref: 'https://example.org/test' }] } } } });
  16. test('统计卡从同一n/N计算占比,保留范围、限制、产品含义和实际证据键', () => {
  17. const [row] = statisticsRows(section());
  18. assert.equal(row.percent, '40.0%');assert.equal(row.ratio, 0.4);
  19. const html = renderReportHtml(task());
  20. for (const value of ['40.0%', 'width:40%', '逐条判定10条有效记录', '部分支持,不等于结论成立', '先核对照样品', '合成原话,只用于测试']) assert.ok(html.includes(value), value);
  21. assert.match(html, /href="#evidence-1"/);assert.match(html, /查看统计汇总表与口径/);
  22. });
  23. test('未核分母或无有效材料不编造百分比,已确认零提及保留0%', () => {
  24. for (const [n, N] of [[null, null], [4, null], [null, 10], [0, 0]]) {
  25. const t = task();t.report.chapters[0].sections[0].data.rows[0].splice(1, 2, n, N);
  26. assert.equal(statisticsRows(t.report.chapters[0].sections[0])[0].percent, null);
  27. assert.doesNotMatch(renderReportHtml(t), /role="img" aria-label="本样本内占比|NaN%|Infinity%/);
  28. }
  29. const s = section();s.data.rows[0][1] = 0;assert.equal(statisticsRows(s)[0].percent, '0.0%');
  30. });
  31. test('非法数值、口径缺失、错列和不存在的证据键在保存前拒绝', async () => {
  32. for (const [n, N] of [[11, 10], [-1, 10], [4.5, 10], ['4', 10], [1, 0], [1, -1]]) {
  33. const s = section();s.data.rows[0].splice(1, 2, n, N);assert.throws(() => statisticsRows(s), { code: 'REPORT_STATISTICS_INVALID' });
  34. }
  35. const s = section();s.data.rows[0][3] = '';assert.throws(() => statisticsRows(s), { code: 'REPORT_STATISTICS_INVALID' });
  36. const t = task();t.report.evidence = {};let called = false;
  37. assert.throws(() => renderReportHtml(t), { code: 'REPORT_STATISTICS_INVALID' });
  38. await assert.rejects(saveAndVerifyReport(t, { requestId: 'test', invoke: async () => { called = true; } }), { code: 'REPORT_STATISTICS_INVALID' });
  39. assert.equal(called, false);
  40. const wrong = section();wrong.data.cols.reverse();assert.throws(() => statisticsRows(wrong), { code: 'REPORT_STATISTICS_INVALID' });
  41. assert.equal(statisticsRows({ kind: 'table', data: { rows: [] } }), null, '普通表不受此语义模板限制');
  42. });
  43. test('多标签主题可重叠,不要求各主题相加等于100%,原始数量不被修改', () => {
  44. const s = section();s.data.rows[0][1] = 8;s.data.rows.push(['主题B', 7, 10, '同10条合成测试记录;多标签重叠', '不能相加为总体比例', '比较场景差异', '']);
  45. const original = structuredClone(s), rows = statisticsRows(s);
  46. assert.deepEqual(rows.map(r => r.percent), ['80.0%', '70.0%']);assert.deepEqual(s, original);
  47. });
  48. test('看板排版薄空格不改变统计识别,浏览器与HTML的统计校验保持同步', () => {
  49. const s = section();s.data.cols = s.data.cols.map(value => value.replaceAll(' ', '\u2009'));
  50. const original = structuredClone(s);
  51. assert.equal(statisticsRows(s)[0].percent, '40.0%');
  52. assert.deepEqual(boardStatisticsRows(s), statisticsRows(s));
  53. assert.deepEqual(s, original, '仅对列名匹配消除排版空白,不改写数据');
  54. s.data.rows[0][1] = 11;
  55. for (const parse of [statisticsRows, boardStatisticsRows]) assert.throws(() => parse(s), { code: 'REPORT_STATISTICS_INVALID' });
  56. assert.equal(fs.readFileSync(new URL('../src/core/report-statistics.js', import.meta.url), 'utf8'),
  57. '// Generated by scripts/build-workshop-skill.mjs; maintain the Skill report-statistics.mjs source.\n' + fs.readFileSync(new URL('../skills/ksf-workshop-runtime/scripts/report-statistics.mjs', import.meta.url), 'utf8'));
  58. });
  59. test('统计表完整保留在默认HTML附件与同版report,生成器不从精选引文数量推断分子', t => {
  60. const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-statistics-test-'));
  61. t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
  62. const original = task(), prepared = prepareHtmlReport(original, { directory, dataMode: 'sample' });
  63. assert.deepEqual(prepared.task.report.chapters, original.report.chapters);
  64. const html = fs.readFileSync(prepared.htmlPath, 'utf8');assert.match(html, /40.0%/);
  65. assert.equal(original.report.evidence.e.quotes.length, 1);assert.equal(statisticsRows(original.report.chapters[0].sections[0])[0].numerator, 4);
  66. validateReportStatistics(original.report);
  67. });