/** * 分流台純函式測試(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08) * * 驗證兩份 content 契約 → 統一分流項的判定: * 1. todo 契約 {"text","marker","owner_tier","project","source","status"} 照 render * 2. inbox 契約 {"text","from","status"} 無 tier → 歸 collab 欄+unclassified 標記 * 3. 三欄計數只算未完成(status:done 不佔欄)、per-project 清單去重排序 * 4. 防禦:owner_tier 看不懂/content 非 JSON 不得讓項目蒸發或冒領 ai 欄 * * 樣本形態對照 leo21c live KBDB(epoch 秒 created_at)與 kb-ingest SDD R5/R7 的 * Logseq marker 詞彙(TODO/DOING/LATER/NOW),非憑空編造。 */ import { describe, it, expect } from 'vitest'; import { todoToTriageItem, inboxToTriageItem, buildTriageModel, applyTriageCheck } from '../src/lib/console-triage-model'; import type { KbdbEntry } from '../src/lib/console-dashboard-model'; const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000); function entry(created: string, content: unknown, type = 'todo'): KbdbEntry { return { id: crypto.randomUUID(), entry_type: type, content: typeof content === 'string' ? content : JSON.stringify(content), created_at: SEC(created), }; } describe('todoToTriageItem — Logseq todo 契約', () => { it('契約全欄照收(text/marker/owner_tier/project/source/status)', () => { const it_ = todoToTriageItem( entry('2026-07-08T02:00:00Z', { text: '把 KBDB 憑證輪替', marker: 'TODO', owner_tier: 'ai', project: 'Arcrun', source: 'notes', status: 'new', }), ); expect(it_.text).toBe('把 KBDB 憑證輪替'); expect(it_.marker).toBe('TODO'); expect(it_.tier).toBe('ai'); expect(it_.unclassified).toBe(false); expect(it_.project).toBe('Arcrun'); expect(it_.source).toBe('notes'); expect(it_.status).toBe('new'); expect(it_.origin).toBe('todo'); expect(it_.at_ms).toBe(Date.parse('2026-07-08T02:00:00Z')); }); it('三欄值 ai/collab/leo 都認;issue 舊詞 mira80/collab15/leo5 當同義收', () => { const tier = (v: unknown) => todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', owner_tier: v })); expect(tier('collab').tier).toBe('collab'); expect(tier('leo').tier).toBe('leo'); expect(tier('leo').unclassified).toBe(false); expect(tier('mira80').tier).toBe('ai'); expect(tier('collab15').tier).toBe('collab'); expect(tier('leo5').tier).toBe('leo'); }); it('owner_tier 缺席/看不懂 → 歸 collab 欄+unclassified,不冒領 ai、不蒸發', () => { const noTier = todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: '還沒 triage 的', marker: 'LATER' })); expect(noTier.tier).toBe('collab'); expect(noTier.unclassified).toBe(true); const weird = todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', owner_tier: 'boss' })); expect(weird.tier).toBe('collab'); expect(weird.unclassified).toBe(true); }); it('content 非 JSON(不合契約殘資料)→ 原文當 text 誠實顯示,不丟棄', () => { const it_ = todoToTriageItem(entry('2026-07-08T02:00:00Z', 'TODO 裸文字殘資料')); expect(it_.text).toBe('TODO 裸文字殘資料'); expect(it_.tier).toBe('collab'); expect(it_.unclassified).toBe(true); expect(it_.status).toBe('new'); }); it('status 只有 done 算完成,其餘(含缺席/怪值)一律 new', () => { expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', status: 'done' })).status).toBe('done'); expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', status: 'wip' })).status).toBe('new'); expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x' })).status).toBe('new'); }); }); describe('inboxToTriageItem — Telegram inbox 契約', () => { it('{"text","from","status"} 沿用:無 tier 歸 collab+unclassified,source 帶 from', () => { const it_ = inboxToTriageItem(entry('2026-07-08T03:00:00Z', { text: '幫我查憑證', from: 'leo', status: 'new' }, 'inbox')); expect(it_.text).toBe('幫我查憑證'); expect(it_.tier).toBe('collab'); expect(it_.unclassified).toBe(true); expect(it_.source).toBe('telegram・leo'); expect(it_.project).toBe(''); expect(it_.marker).toBe(''); expect(it_.origin).toBe('inbox'); }); it('無 from → source 仍標 telegram;status done 照收', () => { const it_ = inboxToTriageItem(entry('2026-07-08T03:00:00Z', { text: 'x', status: 'done' }, 'inbox')); expect(it_.source).toBe('telegram'); expect(it_.status).toBe('done'); }); }); describe('buildTriageModel — 二源合一 / 三欄計數 / per-project', () => { const todo = [ entry('2026-07-08T02:00:00Z', { text: 'A', owner_tier: 'ai', project: 'Arcrun', status: 'new' }), entry('2026-07-08T01:00:00Z', { text: 'B', owner_tier: 'ai', project: 'Arcrun', status: 'done' }), entry('2026-07-07T23:00:00Z', { text: 'C', owner_tier: 'leo', project: 'mira', status: 'new' }), entry('2026-07-07T22:00:00Z', { text: 'D', project: '', status: 'new' }), // 未分流+未分項 ]; const inbox = [ entry('2026-07-08T04:00:00Z', { text: 'E', from: 'leo', status: 'new' }, 'inbox'), entry('2026-07-07T21:00:00Z', { text: 'F', status: 'done' }, 'inbox'), ]; it('合併排序=created_at 新→舊,跨兩源交錯', () => { const m = buildTriageModel(todo, inbox); expect(m.items.map((i) => i.text)).toEqual(['E', 'A', 'B', 'C', 'D', 'F']); expect(m.counts.total).toBe(6); }); it('三欄計數只算未完成:done 不佔欄;未分流(todo 無 tier + inbox 全部)歸 collab', () => { const m = buildTriageModel(todo, inbox); expect(m.counts.ai).toBe(1); // A(B 是 done) expect(m.counts.collab).toBe(2); // D(未分流)+ E(inbox) expect(m.counts.leo).toBe(1); // C expect(m.counts.unclassified).toBe(2); // D + E expect(m.counts.done).toBe(2); // B + F }); it('projects 去重、字典序、不含空字串(未分項由 UI 承接)', () => { const m = buildTriageModel(todo, inbox); expect(m.projects).toEqual(['Arcrun', 'mira']); }); it('空庫誠實回空,不編資料', () => { const m = buildTriageModel([], []); expect(m.items).toHaveLength(0); expect(m.projects).toEqual([]); expect(m.counts).toEqual({ ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: 0 }); }); }); describe('applyTriageCheck — console 勾掉/還原(雙向銷案語意,leo 2026-07-08 拍板)', () => { const NOW = '2026-07-08T12:00:00.000Z'; it('check:status→done + checked_via:console + checked_at,其餘欄位原樣保留', () => { const before = JSON.stringify({ text: '整理 sprint 板', marker: 'TODO', owner_tier: 'leo', project: 'Arcrun', source: 'notes', status: 'new', }); const after = JSON.parse(applyTriageCheck(before, 'check', NOW)); expect(after).toEqual({ text: '整理 sprint 板', marker: 'TODO', owner_tier: 'leo', project: 'Arcrun', source: 'notes', status: 'done', checked_via: 'console', checked_at: NOW, }); }); it('check:inbox 契約 {"text","from","status"} 同樣適用,不掉 from', () => { const after = JSON.parse(applyTriageCheck(JSON.stringify({ text: '買咖啡豆', from: 'leo', status: 'new' }), 'check', NOW)); expect(after.from).toBe('leo'); expect(after.status).toBe('done'); expect(after.checked_via).toBe('console'); }); it('check:content 非 JSON(不合契約殘資料)→ 包成 {"text":原文},原文不丟', () => { const after = JSON.parse(applyTriageCheck('裸字串待辦', 'check', NOW)); expect(after).toEqual({ text: '裸字串待辦', status: 'done', checked_via: 'console', checked_at: NOW }); }); it('check:已 done 的再勾=冪等(仍 done,checked_via 蓋成 console)', () => { const before = JSON.stringify({ text: 'x', status: 'done', checked_via: 'logseq' }); const after = JSON.parse(applyTriageCheck(before, 'check', NOW)); expect(after.status).toBe('done'); expect(after.checked_via).toBe('console'); }); it('restore:status→new、移除 checked_via/checked_at,其餘欄位原樣保留', () => { const before = JSON.stringify({ text: '誤勾的事', marker: 'TODO', owner_tier: 'ai', project: 'mira', source: 'notes', status: 'done', checked_via: 'console', checked_at: '2026-07-08T11:00:00.000Z', }); const after = JSON.parse(applyTriageCheck(before, 'restore', NOW)); expect(after).toEqual({ text: '誤勾的事', marker: 'TODO', owner_tier: 'ai', project: 'mira', source: 'notes', status: 'new', }); }); it('restore:content 非 JSON → 包成 {"text":原文,"status":"new"}', () => { const after = JSON.parse(applyTriageCheck('裸字串', 'restore', NOW)); expect(after).toEqual({ text: '裸字串', status: 'new' }); }); it('null/空 content 防禦:不炸,text 誠實為空字串', () => { const after = JSON.parse(applyTriageCheck(null, 'check', NOW)); expect(after.text).toBe(''); expect(after.status).toBe('done'); }); });