import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildModel } from '../src/core/kfs/build.js'; import { forMe, poolOverview, liveBoard, stuckMembers, stageBoards } from '../src/core/kfs/stats.js'; import { traceArtifact, traceInsight, reportEvidence, conclusionToLedger } from '../src/core/evidence/chain.js'; import { reportOverview, findingsOf } from '../src/core/report.js'; /* 一份最小但覆盖各字段形态的「库」:只在测试里构造,不联网 */ const NOW = Date.parse('2026-10-29T12:00:00Z'); const H = 3600000; const ago = (h) => new Date(NOW - h * H).toISOString(); const raw = { project: [{ objectId: 'p1', title: '夜宵项目', ownerName: '张伟', ownerSession: 's1', stage: 'S1品类分析' }], artifact: [ { objectId: 'a1', title: '夜宵卡 - opportunity_card', ownerName: '张伟', ownerSession: 's1', stage: 'S3 机会识别', evidenceLevel: 'supported', artifactType: 'opportunity_card', createdAt: ago(10), uploadMethod: 'session', agentName: '主分身', agentId: 'agent-1', uploadedAt: ago(10) }, { objectId: 'a2', title: '夜宵痛点 - pain_list', ownerName: '李娜', ownerSession: 's2', stage: 'S2需求洞察', evidenceLevel: 'verified', sources: [], createdAt: ago(9) }, { objectId: 'rep', title: '夜宵竞品 - competitor_report', ownerName: '张伟', ownerSession: 's1', stage: 'S1品类分析', evidenceLevel: 'supported', createdAt: ago(8), report: { title: '夜宵洞察', pipeline: [{ label: '采集', value: 2727 }, { label: '去噪', value: 1190 }, { label: '精准', value: 730 }], chapters: [{ id: 'ch2', no: 2, title: '嵌套洞察', stage: 'S2', sections: [ { kind: 'findings', data: [{ id: 'f', conclusion: '软塌是工艺问题', attitude: '…', evidence: 'c1' }] }, { kind: 'findings', data: [{ id: 'f', conclusion: '软塌是工艺问题', attitude: '…', evidence: 'c1' }] }, ] }], evidence: { c1: { claim: '软塌是工艺问题', ownerCount: 2, quotes: [{ text: '泡完就坨', ref: '电商评论', likes: 302 }], derivedFrom: ['a2', 'ghost'] } }, } }, ], insight: [ { objectId: 'i1', title: '拌面口感', derivedFrom: ['a2'], derivedArtifacts: ['a1'] }, { objectId: 'i2', title: '无出身推断', derivedFrom: [], layer: 'sediment' }, ], source: [{ objectId: 'src1', title: '品类概览', sourceType: 'category' }], session: [ { objectId: 's1', studentName: '张伟', stage: 'S5概念设计', msgCount: 20, lastActiveAt: ago(20) }, { objectId: 's3', studentName: '何雨', stage: 'S4验证评估', msgCount: 13, lastActiveAt: ago(30) }, // 开工了、没交、超时 → 卡住 { objectId: 's4', studentName: '新人', stage: 'S1品类分析', msgCount: 2, lastActiveAt: ago(30) }, // 消息太少 → 不算卡住 { objectId: 's5', studentName: '老数据', stage: 'S2需求洞察', msgCount: 9, createdAt: ago(40) }, // 没有 lastActiveAt ], opportunity: [ { objectId: 'o1', title: '夜宵低卡', stage: 'S3 机会识别', priority: 'S', status: 'candidate', evidenceLevel: 'high', linkedArtifacts: ['a1'], linkedProject: 'p1', scores: [{ user: '李娜', score: 86, at: ago(3) }, { user: '吴敏', score: 90, at: ago(2) }], uploadedBy: '张伟', uploadedByKey: 's1', agentName: '主分身', uploadMethod: 'session', uploadedAt: ago(6) }, { objectId: 'o2', title: '银发低钠', stage: 'S1 品类市场分析', priority: 'B', status: 'approved', evidenceLevel: 'medium', linkedArtifacts: ['ghost'], scores: [], uploadedBy: '赵磊', agentName: '调研 Agent', uploadMethod: 'skill', uploadedAt: ago(1) }, ], }; const db = buildModel(raw, { now: NOW }); test('模型:成果 / 成员 / 机会卡计数与归属', () => { assert.equal(db.artifacts.length, 3); assert.equal(db.members.length, 6, '张伟/李娜/何雨/新人/老数据 + 机会卡上传人赵磊'); assert.equal(db.artifactById.a1.shortTitle, '夜宵卡'); assert.equal(db.artifactById.a2.evidenceLabel, '有佐证', 'verified 但无原文 → 降级'); assert.equal(db.artifactById.a1.layer, 'sediment', '被归纳引用且有出身 → 沉淀'); assert.equal(db.insightById.i2.layer, 'record', '没有出身的归纳只能留档'); assert.deepEqual(db.artifactById.a2.citedByInsightIds, ['i1']); }); test('视角一「给我的」:交了几份、哪个阶段没交', () => { const m = forMe(db, '张伟'); assert.equal(m.count, 2); assert.deepEqual(m.stagesDone, ['S1', 'S3']); assert.deepEqual(m.stagesMissing, ['S2', 'S4', 'S5']); assert.equal(m.opps.length, 1); }); test('视角二「池子」:总数 / 状态分布 / 最新 / 最高分', () => { const p = poolOverview(db); assert.equal(p.total, 2); assert.deepEqual(p.byStatus, { candidate: 1, approved: 1, rejected: 0 }); assert.equal(p.latest3[0].id, 'o2', '最新上传在前'); assert.equal(p.top.id, 'o1'); assert.equal(p.top.stats.avg, 88); }); test('卡住:只列开工了、没交、超时的人名', () => { const s = stuckMembers(db); assert.deepEqual(s.map((x) => x.name), ['老数据', '何雨'], '老数据没有 lastActiveAt,按会话 createdAt(40h) 算,也确实超时'); assert.equal(s[1].stage, 'S4\u2009机会验证评估'); assert.equal(s[1].stuckHours, 30); }); test('视角三「现场看板」:五阶段 × 人', () => { const b = liveBoard(db); assert.equal(b.stages.length, 5); const s3 = b.stages.find((x) => x.stage.code === 'S3'); assert.equal(s3.submitted, 1); assert.equal(s3.people.find((p) => p.name === '张伟').count, 1); assert.equal(b.stalled.length, 2); }); test('五阶段板块:产出数 + 最新 + 谁产的', () => { const b = stageBoards(db); const s2 = b.find((x) => x.stage.code === 'S2'); assert.equal(s2.count, 1); assert.deepEqual(s2.producers, [{ name: '李娜', count: 1 }]); assert.equal(b.find((x) => x.stage.code === 'S3').oppCount, 1, '机会卡按阶段单独算,不混进成果数'); }); test('证据链:结论 → 具体成果名和人名(不存在的如实标出)', () => { const e = reportEvidence(db, 'rep', 'c1'); assert.equal(e.claim, '软塌是工艺问题'); assert.deepEqual(e.derived.map((d) => d.name), ['夜宵痛点', '(库里已不存在)']); assert.deepEqual(e.derivedOwners, ['李娜']); assert.deepEqual(conclusionToLedger(db, 'rep', 'c1'), ['a2', 'ghost']); assert.equal(e.quotes[0].likes, 302); }); test('证据链:成果 → 引用它的结论 / 归纳 / 机会卡', () => { const t = traceArtifact(db, 'a2'); assert.deepEqual([...new Set(t.citing.map((c) => `${c.reportTitle}/${c.evId}`))], ['夜宵竞品/c1']); assert.deepEqual(t.insights.map((i) => i.title), ['拌面口感']); const t1 = traceArtifact(db, 'a1'); assert.deepEqual(t1.opportunities.map((o) => o.title), ['夜宵低卡']); const ti = traceInsight(db, 'i1'); assert.equal(ti.reports.length, 0, 'i1 只引用了不带报告正文的成果,如实为空'); assert.deepEqual(traceInsight(db, 'i1').artifacts.map((a) => a.name), ['夜宵卡']); });