showcase.test.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  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. assert.equal(c.types.find((t) => t.type.key === 'competitor').count, 1);
  30. assert.equal(c.types.find((t) => t.type.key === 'voc').count, 0);
  31. assert.equal(c.types.find((t) => t.type.key === 'opportunities').count, 1);
  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, 0);
  34. const sum = c.types.reduce((n, t) => n + t.count, 0);
  35. assert.equal(sum, 3, '已归类合计只含成果报告');
  36. assert.equal(c.unclassified.length, 0);
  37. assert.equal(c.total, 3);
  38. assert.deepEqual(c.separateObjects, { opportunities: 2, sources: 1 });
  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, 0, '机会卡报告已有独立分类');
  54. assert.equal(filterEntries(all, { stage: 'S1' }).length, 1, '竞品分类只计rep');
  55. assert.equal(filterEntries(entriesForType(db, 'opportunities'), { stage: 'S3' }).length, 1);
  56. // 有证据 = 成果原始等级 verified/supported,或机会卡的高/中;「只看有证据」必须真的筛掉推测
  57. const withEv = filterEntries(all, { evidenceOnly: true });
  58. assert.ok(withEv.every(hasEvidence));
  59. // 本 fixture 全是 supported / 高 证据,筛后应全留;另造一条推测验证会被筛掉
  60. assert.equal(withEv.length, all.length);
  61. const spec = { kind: 'artifact', evidenceKey: 'speculative', original: 'speculative' };
  62. assert.equal(hasEvidence(spec), false, '模型推测不算有证据');
  63. assert.equal(filterEntries([spec], { evidenceOnly: true }).length, 0);
  64. assert.equal(filterEntries(all, { mine: '张伟' }).length, 1, '仅rep属于竞品报告');
  65. });
  66. test('卡片关键指标按列对齐:每张卡 4 个指标,与类型页列名一致', () => {
  67. for (const t of REPORT_TYPES) {
  68. assert.equal(t.metricLabels.length, 4);
  69. for (const e of entriesForType(db, t.key)) assert.equal(e.metrics.length, 4, `${t.key}/${e.id}`);
  70. }
  71. });
  72. test('阶段产出:按类型分组 + 机会卡单独成组 + 产出人', () => {
  73. const s3 = stageOutcome(db, 'S3');
  74. assert.equal(s3.total, 2);
  75. const opp = s3.groups.find((g) => g.key === 'opportunity');
  76. assert.equal(opp.count, 1);
  77. assert.equal(opp.items[0].title, '夜宵低卡');
  78. assert.deepEqual(s3.owners.map((o) => o.name).sort(), ['张伟']);
  79. assert.equal(stageOutcome(db, 'S4').total, 0, '没有产出的阶段给 0,不编');
  80. });
  81. test('痛点归并:同一条痛点(同一报告结论)多人整理只算一份;没标严重度就是 null', () => {
  82. const d2 = buildModel({ artifact: [
  83. { objectId: 'p1', title: '软塌 - pain_list', ownerName: '甲', stage: 'S2', artifactType: 'pain_list', report: { evidence: { e1: { claim: '泡完就坨' } } } },
  84. { objectId: 'p2', title: '泡完就坨 - pain_list', ownerName: '乙', stage: 'S2', artifactType: 'pain_list', report: { evidence: { e1: { claim: '泡完就坨' } } } },
  85. { objectId: 'p3', title: '太贵 - pain_list', ownerName: '丙', stage: 'S2', artifactType: 'pain_list' },
  86. ] }, { now: NOW });
  87. const rows = painRanking(d2, entriesForType(d2, 'pain'));
  88. assert.equal(rows[0].subject, '泡完就坨');
  89. assert.equal(rows[0].mentions, 2);
  90. assert.deepEqual(rows[0].people.sort(), ['乙', '甲']);
  91. assert.equal(rows.find((r) => r.subject === '太贵').severity, null);
  92. });
  93. test('报告可扫:章节摘要只抽原文,不生成', () => {
  94. const dig = chapterDigests(db, 'rep');
  95. assert.equal(dig[0].title, '嵌套洞察');
  96. assert.equal(dig[0].findings[0], '软塌是工艺问题');
  97. assert.deepEqual(sectionDigest({ kind: 'findings', data: [{ conclusion: '软塌是工艺问题' }] }), ['软塌是工艺问题']);
  98. assert.deepEqual(sectionDigest({ kind: 'para', data: { text: '短' } }), []);
  99. });
  100. test('竞品矩阵:按对位主题合并成一行', () => {
  101. const m = competitorMatrix(entriesForType(db, 'competitor'));
  102. const row = m.find((r) => r.subject === '夜宵竞品');
  103. assert.equal(row.count, 1, '独立机会o2不冒充竞品报告');
  104. assert.deepEqual(row.stageCodes.sort(), ['S1']);
  105. });
  106. test('空态说明书:每个类型都写清将来会出现什么(不是「暂无数据」)', () => {
  107. for (const t of REPORT_TYPES) {
  108. const p = EMPTY_PLAN[t.key];
  109. assert.ok(p?.will && p?.who && p?.field && p?.looks, t.key);
  110. assert.ok(!/暂无数据/.test(JSON.stringify(p)));
  111. }
  112. });