| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109 |
- import { test } from 'node:test';
- import assert from 'node:assert/strict';
- import { buildModel } from '../src/core/kfs/build.js';
- import {
- REPORT_TYPES, entriesForType, filterEntries, reportCenter, stageOutcome, painRanking,
- sectionDigest, chapterDigests, competitorMatrix, EMPTY_PLAN, hasEvidence,
- } from '../src/core/index.js';
- const NOW = Date.parse('2026-10-29T12:00:00Z');
- const ago = (h) => new Date(NOW - h * 3600000).toISOString();
- const raw = {
- artifact: [
- { objectId: 'a1', title: '夜宵卡 - opportunity_card', ownerName: '张伟', stage: 'S3 机会识别', evidenceLevel: 'supported', artifactType: 'opportunity_card', createdAt: ago(10) },
- { objectId: 'a2', title: '夜宵痛点 - pain_list', ownerName: '李娜', stage: 'S2需求洞察', evidenceLevel: 'verified', artifactType: 'pain_list', createdAt: ago(9) },
- { objectId: 'rep', title: '夜宵竞品 - competitor_report', ownerName: '张伟', stage: 'S1品类分析', evidenceLevel: 'supported', artifactType: 'competitor_report', createdAt: ago(8),
- report: { title: '夜宵洞察', executive: ['夜宵是最大空档'], chapters: [{ id: 'ch2', no: 2, title: '嵌套洞察', sections: [
- { kind: 'findings', data: [{ conclusion: '软塌是工艺问题', evidence: 'c1' }] },
- ] }], evidence: { c1: { claim: '软塌是工艺问题', build: undefined, quotes: [{ text: '泡完就坨', ref: '电商评论', likes: 302 }] } } } },
- ],
- opportunity: [
- { objectId: 'o1', title: '夜宵低卡', category: '拌面', stage: 'S3 机会识别', priority: 'S', evidenceLevel: 'high', linkedArtifacts: ['a1'], uploadedBy: '张伟', agentName: '主分身', uploadMethod: 'session', uploadedAt: ago(6) },
- { objectId: 'o2', title: '夜宵竞品', category: '汤面', stage: 'S1 品类市场分析', priority: 'B', evidenceLevel: 'medium', linkedArtifacts: [], uploadedBy: '赵磊', agentName: '调研 Agent', uploadMethod: 'skill', uploadedAt: ago(1) },
- ],
- source: [{ objectId: 'c1', title: '拌面品类概览', sourceType: 'category', tags: ['拌面'] }],
- };
- const db = buildModel(raw, { now: NOW });
- test('报告中心:五类都有数,未映射成果如实进「未归类」', () => {
- const c = reportCenter(db, { now: NOW });
- assert.deepEqual(c.types.map((t) => t.type.key), REPORT_TYPES.map((t) => t.key));
- // competitor: a1 + rep + o1 + o2;voc: o1(机会卡的证据链也进 VOC 页);pain: a2;category: 1 份资料
- assert.equal(c.types.find((t) => t.type.key === 'competitor').count, 4);
- assert.equal(c.types.find((t) => t.type.key === 'voc').count, 2, 'o1 + o2:机会卡的可引用证据也进 VOC 页');
- assert.equal(c.types.find((t) => t.type.key === 'pain').count, 1);
- assert.equal(c.types.find((t) => t.type.key === 'category').count, 1);
- const sum = c.types.reduce((n, t) => n + t.count, 0);
- // 合计 = 成果 3 + 机会卡 2(competitor)+ 机会卡 2(voc)+ 资料 1 = 8;没有未归类(同一张卡可被两个视角引用)
- assert.equal(sum, 8, '已归类合计');
- assert.equal(c.unclassified.length, 0);
- assert.ok(c.types.every((t) => t.share >= 0 && t.relative >= 0), '每个数字都有参照');
- });
- test('类型页:阶段二次筛选 + 只看有证据', () => {
- const all = entriesForType(db, 'competitor');
- assert.equal(filterEntries(all, { stage: 'S3' }).length, 2, 'a1 与 o1 都在 S3');
- assert.equal(filterEntries(all, { stage: 'S1' }).length, 2, 'rep 与 o2 都在 S1');
- // 有证据 = 成果原始等级 verified/supported,或机会卡的高/中;「只看有证据」必须真的筛掉推测
- const withEv = filterEntries(all, { evidenceOnly: true });
- assert.ok(withEv.every(hasEvidence));
- // 本 fixture 全是 supported / 高 证据,筛后应全留;另造一条推测验证会被筛掉
- assert.equal(withEv.length, all.length);
- const spec = { kind: 'artifact', evidenceKey: 'speculative', original: 'speculative' };
- assert.equal(hasEvidence(spec), false, '模型推测不算有证据');
- assert.equal(filterEntries([spec], { evidenceOnly: true }).length, 0);
- assert.equal(filterEntries(all, { mine: '张伟' }).length, 3, 'a1 / rep / o1 都是张伟');
- });
- test('卡片关键指标按列对齐:每张卡 4 个指标,与类型页列名一致', () => {
- for (const t of REPORT_TYPES) {
- assert.equal(t.metricLabels.length, 4);
- for (const e of entriesForType(db, t.key)) assert.equal(e.metrics.length, 4, `${t.key}/${e.id}`);
- }
- });
- test('阶段产出:按类型分组 + 机会卡单独成组 + 产出人', () => {
- const s3 = stageOutcome(db, 'S3');
- assert.equal(s3.total, 2);
- const opp = s3.groups.find((g) => g.key === 'opportunity');
- assert.equal(opp.count, 1);
- assert.equal(opp.items[0].title, '夜宵低卡');
- assert.deepEqual(s3.owners.map((o) => o.name).sort(), ['张伟']);
- assert.equal(stageOutcome(db, 'S4').total, 0, '没有产出的阶段给 0,不编');
- });
- test('痛点归并:同一条痛点(同一报告结论)多人整理只算一份;没标严重度就是 null', () => {
- const d2 = buildModel({ artifact: [
- { objectId: 'p1', title: '软塌 - pain_list', ownerName: '甲', stage: 'S2', artifactType: 'pain_list', report: { evidence: { e1: { claim: '泡完就坨' } } } },
- { objectId: 'p2', title: '泡完就坨 - pain_list', ownerName: '乙', stage: 'S2', artifactType: 'pain_list', report: { evidence: { e1: { claim: '泡完就坨' } } } },
- { objectId: 'p3', title: '太贵 - pain_list', ownerName: '丙', stage: 'S2', artifactType: 'pain_list' },
- ] }, { now: NOW });
- const rows = painRanking(d2, entriesForType(d2, 'pain'));
- assert.equal(rows[0].subject, '泡完就坨');
- assert.equal(rows[0].mentions, 2);
- assert.deepEqual(rows[0].people.sort(), ['乙', '甲']);
- assert.equal(rows.find((r) => r.subject === '太贵').severity, null);
- });
- test('报告可扫:章节摘要只抽原文,不生成', () => {
- const dig = chapterDigests(db, 'rep');
- assert.equal(dig[0].title, '嵌套洞察');
- assert.equal(dig[0].findings[0], '软塌是工艺问题');
- assert.deepEqual(sectionDigest({ kind: 'findings', data: [{ conclusion: '软塌是工艺问题' }] }), ['软塌是工艺问题']);
- assert.deepEqual(sectionDigest({ kind: 'para', data: { text: '短' } }), []);
- });
- test('竞品矩阵:按对位主题合并成一行', () => {
- const m = competitorMatrix(entriesForType(db, 'competitor'));
- const row = m.find((r) => r.subject === '夜宵竞品');
- assert.equal(row.count, 2, 'rep 与 o2 同主题');
- assert.deepEqual(row.stageCodes.sort(), ['S1']);
- });
- test('空态说明书:每个类型都写清将来会出现什么(不是「暂无数据」)', () => {
- for (const t of REPORT_TYPES) {
- const p = EMPTY_PLAN[t.key];
- assert.ok(p?.will && p?.who && p?.field && p?.looks, t.key);
- assert.ok(!/暂无数据/.test(JSON.stringify(p)));
- }
- });
|