showcase.test.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import { test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { buildModel } from '../src/core/kfs/build.js';
  4. import {
  5. REPORT_TYPES, entriesForType, filterEntries, reportCenter, stageOutcome, painRanking,
  6. sectionDigest, chapterDigests, competitorMatrix, EMPTY_PLAN, hasEvidence,
  7. } from '../src/core/index.js';
  8. const NOW = Date.parse('2026-10-29T12:00:00Z');
  9. const ago = (h) => new Date(NOW - h * 3600000).toISOString();
  10. const raw = {
  11. artifact: [
  12. { objectId: 'a1', title: '夜宵卡 - opportunity_card', ownerName: '张伟', stage: 'S3 机会识别', evidenceLevel: 'supported', artifactType: 'opportunity_card', createdAt: ago(10) },
  13. { objectId: 'a2', title: '夜宵痛点 - pain_list', ownerName: '李娜', stage: 'S2需求洞察', evidenceLevel: 'verified', artifactType: 'pain_list', createdAt: ago(9) },
  14. { objectId: 'rep', title: '夜宵竞品 - competitor_report', ownerName: '张伟', stage: 'S1品类分析', evidenceLevel: 'supported', artifactType: 'competitor_report', createdAt: ago(8),
  15. report: { title: '夜宵洞察', executive: ['夜宵是最大空档'], chapters: [{ id: 'ch2', no: 2, title: '嵌套洞察', sections: [
  16. { kind: 'findings', data: [{ conclusion: '软塌是工艺问题', evidence: 'c1' }] },
  17. ] }], evidence: { c1: { claim: '软塌是工艺问题', build: undefined, quotes: [{ text: '泡完就坨', ref: '电商评论', likes: 302 }] } } } },
  18. ],
  19. opportunity: [
  20. { objectId: 'o1', title: '夜宵低卡', category: '拌面', stage: 'S3 机会识别', priority: 'S', evidenceLevel: 'high', linkedArtifacts: ['a1'], uploadedBy: '张伟', agentName: '主分身', uploadMethod: 'session', uploadedAt: ago(6) },
  21. { objectId: 'o2', title: '夜宵竞品', category: '汤面', stage: 'S1 品类市场分析', priority: 'B', evidenceLevel: 'medium', linkedArtifacts: [], uploadedBy: '赵磊', agentName: '调研 Agent', uploadMethod: 'skill', uploadedAt: ago(1) },
  22. ],
  23. source: [{ objectId: 'c1', title: '拌面品类概览', sourceType: 'category', tags: ['拌面'] }],
  24. };
  25. const db = buildModel(raw, { now: NOW });
  26. test('报告中心:五类都有数,未映射成果如实进「未归类」', () => {
  27. const c = reportCenter(db, { now: NOW });
  28. assert.deepEqual(c.types.map((t) => t.type.key), REPORT_TYPES.map((t) => t.key));
  29. // competitor: a1 + rep + o1 + o2;voc: o1(机会卡的证据链也进 VOC 页);pain: a2;category: 1 份资料
  30. assert.equal(c.types.find((t) => t.type.key === 'competitor').count, 4);
  31. assert.equal(c.types.find((t) => t.type.key === 'voc').count, 2, 'o1 + o2:机会卡的可引用证据也进 VOC 页');
  32. assert.equal(c.types.find((t) => t.type.key === 'pain').count, 1);
  33. assert.equal(c.types.find((t) => t.type.key === 'category').count, 1);
  34. const sum = c.types.reduce((n, t) => n + t.count, 0);
  35. // 合计 = 成果 3 + 机会卡 2(competitor)+ 机会卡 2(voc)+ 资料 1 = 8;没有未归类(同一张卡可被两个视角引用)
  36. assert.equal(sum, 8, '已归类合计');
  37. assert.equal(c.unclassified.length, 0);
  38. assert.equal(c.total, 6, '中心按稳定编号去重,类型页可以分别引用同一机会卡');
  39. assert.equal(new Set(c.entries.map(e => `${e.kind}:${e.id}`)).size, c.total);
  40. assert.ok(c.types.every((t) => t.share >= 0 && t.relative >= 0), '每个数字都有参照');
  41. });
  42. test('中心清单保留未归类成果,可打开正文,不被五个类型过滤掉', () => {
  43. const extra = buildModel({ ...raw, artifact: [...raw.artifact, { objectId: 'unknown1', title: '待归类原文', artifactType: 'unknown', rawText: '尚未归类的成果原文。' }] });
  44. const c = reportCenter(extra);
  45. assert.equal(c.unclassified.length, 1);
  46. const entry = c.entries.find(e => e.id === 'unknown1');
  47. assert.equal(entry.kind, 'artifact');
  48. assert.equal(entry.typeName, '未归类');
  49. assert.equal(entry.body, 'text');
  50. });
  51. test('类型页:阶段二次筛选 + 只看有证据', () => {
  52. const all = entriesForType(db, 'competitor');
  53. assert.equal(filterEntries(all, { stage: 'S3' }).length, 2, 'a1 与 o1 都在 S3');
  54. assert.equal(filterEntries(all, { stage: 'S1' }).length, 2, 'rep 与 o2 都在 S1');
  55. // 有证据 = 成果原始等级 verified/supported,或机会卡的高/中;「只看有证据」必须真的筛掉推测
  56. const withEv = filterEntries(all, { evidenceOnly: true });
  57. assert.ok(withEv.every(hasEvidence));
  58. // 本 fixture 全是 supported / 高 证据,筛后应全留;另造一条推测验证会被筛掉
  59. assert.equal(withEv.length, all.length);
  60. const spec = { kind: 'artifact', evidenceKey: 'speculative', original: 'speculative' };
  61. assert.equal(hasEvidence(spec), false, '模型推测不算有证据');
  62. assert.equal(filterEntries([spec], { evidenceOnly: true }).length, 0);
  63. assert.equal(filterEntries(all, { mine: '张伟' }).length, 3, 'a1 / rep / o1 都是张伟');
  64. });
  65. test('卡片关键指标按列对齐:每张卡 4 个指标,与类型页列名一致', () => {
  66. for (const t of REPORT_TYPES) {
  67. assert.equal(t.metricLabels.length, 4);
  68. for (const e of entriesForType(db, t.key)) assert.equal(e.metrics.length, 4, `${t.key}/${e.id}`);
  69. }
  70. });
  71. test('阶段产出:按类型分组 + 机会卡单独成组 + 产出人', () => {
  72. const s3 = stageOutcome(db, 'S3');
  73. assert.equal(s3.total, 2);
  74. const opp = s3.groups.find((g) => g.key === 'opportunity');
  75. assert.equal(opp.count, 1);
  76. assert.equal(opp.items[0].title, '夜宵低卡');
  77. assert.deepEqual(s3.owners.map((o) => o.name).sort(), ['张伟']);
  78. assert.equal(stageOutcome(db, 'S4').total, 0, '没有产出的阶段给 0,不编');
  79. });
  80. test('痛点归并:同一条痛点(同一报告结论)多人整理只算一份;没标严重度就是 null', () => {
  81. const d2 = buildModel({ artifact: [
  82. { objectId: 'p1', title: '软塌 - pain_list', ownerName: '甲', stage: 'S2', artifactType: 'pain_list', report: { evidence: { e1: { claim: '泡完就坨' } } } },
  83. { objectId: 'p2', title: '泡完就坨 - pain_list', ownerName: '乙', stage: 'S2', artifactType: 'pain_list', report: { evidence: { e1: { claim: '泡完就坨' } } } },
  84. { objectId: 'p3', title: '太贵 - pain_list', ownerName: '丙', stage: 'S2', artifactType: 'pain_list' },
  85. ] }, { now: NOW });
  86. const rows = painRanking(d2, entriesForType(d2, 'pain'));
  87. assert.equal(rows[0].subject, '泡完就坨');
  88. assert.equal(rows[0].mentions, 2);
  89. assert.deepEqual(rows[0].people.sort(), ['乙', '甲']);
  90. assert.equal(rows.find((r) => r.subject === '太贵').severity, null);
  91. });
  92. test('报告可扫:章节摘要只抽原文,不生成', () => {
  93. const dig = chapterDigests(db, 'rep');
  94. assert.equal(dig[0].title, '嵌套洞察');
  95. assert.equal(dig[0].findings[0], '软塌是工艺问题');
  96. assert.deepEqual(sectionDigest({ kind: 'findings', data: [{ conclusion: '软塌是工艺问题' }] }), ['软塌是工艺问题']);
  97. assert.deepEqual(sectionDigest({ kind: 'para', data: { text: '短' } }), []);
  98. });
  99. test('竞品矩阵:按对位主题合并成一行', () => {
  100. const m = competitorMatrix(entriesForType(db, 'competitor'));
  101. const row = m.find((r) => r.subject === '夜宵竞品');
  102. assert.equal(row.count, 2, 'rep 与 o2 同主题');
  103. assert.deepEqual(row.stageCodes.sort(), ['S1']);
  104. });
  105. test('空态说明书:每个类型都写清将来会出现什么(不是「暂无数据」)', () => {
  106. for (const t of REPORT_TYPES) {
  107. const p = EMPTY_PLAN[t.key];
  108. assert.ok(p?.will && p?.who && p?.field && p?.looks, t.key);
  109. assert.ok(!/暂无数据/.test(JSON.stringify(p)));
  110. }
  111. });