report.test.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import { test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { pipelineSummary, reportOverview, findingsOf, chapters } from '../src/core/report.js';
  4. import { liveBoard, forMe, stuckMembers } from '../src/core/kfs/stats.js';
  5. import { buildModel } from '../src/core/kfs/build.js';
  6. import { reportEvidence, reportConclusions, conclusionToLedger, traceInsight } from '../src/core/evidence/chain.js';
  7. import { latest, fmtAgo, fmtDate, toTime, truncate, clamp } from '../src/core/kfs/util.js';
  8. import { normDecision, cardSummary } from '../src/core/opportunity/opportunity.js';
  9. const raw = {
  10. artifact: [
  11. { objectId: 'r', title: '报告 - competitor_report', ownerName: '张伟', stage: 'S1品类分析',
  12. report: {
  13. title: '测试报告', subtitle: '副标题', pipeline: [{ label: '采集', value: 2727 }, { label: '去噪', value: 1190 }, { label: '精准', value: 730 }],
  14. chapters: [
  15. { id: 'ch1', no: 1, title: '一', stage: 'S1', lead: '问题一', sections: [{ kind: 'para', text: 'x' }] },
  16. { id: 'ch2', no: 2, title: '二', stage: 'S2', lead: '问题二', sections: [{ kind: 'findings', data: [{ conclusion: 'A', evidence: 'e1' }, { conclusion: 'A', evidence: 'e1' }] }] },
  17. { id: 'ch3', no: 3, title: '三', stage: 'S3', sections: [{ kind: 'mystery', data: { a: 1 } }] },
  18. ],
  19. evidence: { e1: { claim: 'A', ownerCount: 3, quotes: [{ text: 'q' }], derivedFrom: ['a1'] } },
  20. } },
  21. { objectId: 'a1', title: '成果一', ownerName: '李娜', stage: 'S2需求洞察' },
  22. ],
  23. insight: [{ objectId: 'i1', title: '归纳', derivedArtifacts: ['a1'] }],
  24. session: [],
  25. project: [],
  26. source: [],
  27. opportunity: [],
  28. };
  29. const db = buildModel(raw);
  30. test('数据管线:首尾推算过滤率', () => {
  31. const p = pipelineSummary({ pipeline: [{ label: 'a', value: 2727 }, { label: 'b', value: 1190 }, { label: 'c', value: 730 }] });
  32. assert.equal(p.filterRate, 73);
  33. assert.equal(p.steps.length, 3);
  34. assert.equal(pipelineSummary({}), null);
  35. });
  36. test('报告概览:章数 / 结论 / 引用 / 原文 / 跨阶段', () => {
  37. const r = reportOverview(db, 'r');
  38. assert.equal(r.chapterCount, 3);
  39. assert.equal(r.conclusionCount, 1);
  40. assert.equal(r.refsInLib, 1);
  41. assert.equal(r.quoteCount, 1);
  42. assert.deepEqual(r.stages, ['S1', 'S2', 'S3']);
  43. assert.equal(r.pipeline.filterRate, 73);
  44. assert.equal(reportOverview(db, 'a1'), null, '没有 report 的成果不进报告目录');
  45. });
  46. test('结论块去重(同一证据锚点只出现一次)', () => {
  47. const f = findingsOf(db, 'r');
  48. assert.equal(f.length, 1);
  49. assert.equal(f[0].ownerCount, 3);
  50. });
  51. test('表格和段落小结的全部逐字引文都有证据入口,来源关系保留', () => {
  52. const report = {
  53. chapters: [{ title: '资料小结', sections: [{ kind: 'para', text: '资料范围' }, { kind: 'table', data: { cols: ['产品'], rows: [['拌面']] } }] }],
  54. evidence: {
  55. competitor: { sourceId: 'source-competitor', claim: '官网口味说明', quotes: [{ text: '官网原文' }] },
  56. own: { sourceId: 'source-own', claim: '2022年品牌通告', quotes: [{ text: '品牌升级' }, { text: '辣味选择' }, { text: '滤水口' }] },
  57. },
  58. };
  59. const model = buildModel({ artifact: [{ objectId: 'summary', report }], source: [] });
  60. const entries = findingsOf(model, 'summary');
  61. assert.deepEqual(entries.map(e => [e.evId, e.quoteCount]), [['competitor', 1], ['own', 3]]);
  62. assert.ok(entries.every(e => e.ownerCount === null), '品牌文案不填消费者人数');
  63. const own = reportEvidence(model, 'summary', 'own');
  64. assert.ok(own.quotes.every(q => q.sourceId === 'source-own' && q.art === null));
  65. assert.equal(reportOverview(model, 'summary').quoteCount, 4);
  66. report.chapters[0].sections.push({ kind: 'findings', data: [{ conclusion: '原有判断', evidence: 'own' }] });
  67. const mixed = findingsOf(model, 'summary');
  68. assert.deepEqual(mixed.map(e => e.evId), ['own', 'competitor']);
  69. assert.equal(mixed[0].conclusion, '原有判断');
  70. assert.equal(mixed[1].conclusion, '官网口味说明');
  71. const fallback = reportEvidence(buildModel({ artifact: [{ objectId: 'empty', report: { evidence: { x: { quotes: [{ text: '无来源' }] } } } }] }), 'empty', 'x');
  72. assert.equal(fallback.quotes[0].sourceId, null);
  73. });
  74. test('章节归一:缺 id 用序号补齐', () => {
  75. const c = chapters({ chapters: [{ title: 'x' }, { id: 'y', title: 'z' }] });
  76. assert.equal(c[0].id, 'ch1');
  77. assert.equal(c[1].no, 2);
  78. });
  79. test('证据锚点 → 台账行 id(含不存在的成果也如实返回)', () => {
  80. db.artifactById.ghost = undefined;
  81. assert.deepEqual(conclusionToLedger(db, 'r', 'e1'), ['a1']);
  82. assert.equal(reportEvidence(db, 'r', 'nope'), null);
  83. assert.deepEqual(reportConclusions(db, 'r').map((c) => c.id), ['e1']);
  84. });
  85. test('归纳 → 来自的报告', () => {
  86. // 现实中归纳引用的成果是带报告的成果本身(reportArtifact),不是旁证;用这种形态验证
  87. const db2 = buildModel({ insight: [{ objectId: 'i2', title: '归纳2', derivedArtifacts: ['r'] }], artifact: raw.artifact });
  88. const t = traceInsight(db2, 'i2');
  89. assert.deepEqual(t.reports.map((r) => r.id), ['r']);
  90. assert.equal(t.reports[0].title, '测试报告');
  91. });
  92. test('时间工具:Parse 日期 / ISO / 毫秒都能读,空值不炸', () => {
  93. assert.equal(toTime({ __type: 'Date', iso: '2026-10-29T00:00:00Z' }), Date.parse('2026-10-29T00:00:00Z'));
  94. assert.equal(toTime(null), 0);
  95. assert.equal(toTime('坏数据'), 0);
  96. assert.equal(latest([null, '2026-01-01T00:00:00Z', '2025-01-01T00:00:00Z']), '2026-01-01T00:00:00.000Z');
  97. assert.equal(fmtDate(null), '—');
  98. assert.equal(fmtAgo(new Date(Date.now() - 3 * 3600000).toISOString()), '3\u2009小时前');
  99. assert.equal(truncate('一二三四五', 3), '一二三…');
  100. assert.equal(clamp(120, 0, 100), 100);
  101. });
  102. test('决策归一:未知类型保留 label,缺 label 给「决策」', () => {
  103. assert.equal(normDecision({ type: 'weird', label: '暂缓(补原文)' }).label, '暂缓(补原文)');
  104. assert.equal(normDecision({ type: 'weird' }).label, '决策');
  105. assert.equal(normDecision(null), null);
  106. });
  107. test('卡片摘要不含内部大对象', () => {
  108. const o = db.opportunities[0] || buildModel({ opportunity: [{ objectId: 'o', title: 'T', oneLiner: 'x'.repeat(200) }] }).opportunities[0];
  109. const s = cardSummary(o, 10);
  110. assert.equal(s.title, 'T');
  111. assert.equal(Object.keys(s).includes('linkedArtifacts'), false);
  112. });
  113. test('现场看板:没有任何会话时不崩', () => {
  114. const b = liveBoard(db);
  115. assert.equal(b.stages.length, 5);
  116. assert.deepEqual(stuckMembers(db), []);
  117. assert.equal(forMe(db, '').count, 0);
  118. });