Files
Arcrun/cypher-executor/tests/console-triage-model.test.ts
T
Leo e90688c608 feat(console): 分流台勾掉/還原(雙向銷案:console 勾掉=終局 checked_via:console,萃取端絕不復活)
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>
2026-07-08 23:30:13 +08:00

213 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 分流台純函式測試(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 KBDBepoch 秒 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 歸 collabunclassifiedsource 帶 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 仍標 telegramstatus 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); // AB 是 done
expect(m.counts.collab).toBe(2); // D(未分流)+ Einbox
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('checkstatus→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('checkinbox 契約 {"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('checkcontent 非 JSON(不合契約殘資料)→ 包成 {"text":原文},原文不丟', () => {
const after = JSON.parse(applyTriageCheck('裸字串待辦', 'check', NOW));
expect(after).toEqual({ text: '裸字串待辦', status: 'done', checked_via: 'console', checked_at: NOW });
});
it('check:已 done 的再勾=冪等(仍 donechecked_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('restorestatus→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('restorecontent 非 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');
});
});