e90688c608
leo 2026-07-08 拍板,PR#41 分流台的後續:
- lib/console-triage-model.ts:applyTriageCheck 純函式(check→status:done+
checked_via:console+checked_at;restore→status:new+移除 checked_via/checked_at;
content 非 JSON 殘資料包成 {"text":原文} 不丟字)。雙向銷案語意寫死在註解:
console 勾掉=終局,即使 Logseq 原文還是 TODO 萃取端也絕不復活;Logseq 改 DONE
由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。
- console-dashboard.ts:POST /console/triage-check(沿 triage-data 同款 session 驗證;
瀏覽器沒 KBDB token 故 server 端做 PATCH——先 GET 原 entry 整串回寫防蓋掉別的欄位;
守 owner_id=CONSOLE_TENANT 與 entry_type∈{todo,inbox} 兩道邊界)。
- console.ts:每筆待辦 44px 圓形勾掉鈕(手機好按)+done 清單「還原」鈕(誤勾救濟);
後端回寫成功才改本地狀態移出三欄(done 預設隱藏既有機制),失敗誠實 toast 可重試。
- 測試:applyTriageCheck 7 例(先紅後綠)+ route 層 8 例(fetchMock 假 host
kbdb.test + disableNetConnect,絕不外連;驗 session 閘/整串回寫/邊界/restore)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
9.1 KiB
TypeScript
213 lines
9.1 KiB
TypeScript
/**
|
||
* 分流台純函式測試(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');
|
||
});
|
||
});
|