showcase.test.js 6.7 KB

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