/** * console 駕駛艙聚合純函式測試(fix/console-dashboard-live-data,2026-07-07) * * 針對 leo 抱怨「等你的事錯了好幾天」的三個 stale 根因逐一驗證: * 1. dash_task 的 scope:"today" 沒日期 → 幾天前的任務不得再算「今日」 * 2. dash_wait 殘資料 fallback 必標 stale * 3. sprint 檔「等 leo 清單」表格解析:已銷案(~~/✅/已完成)要濾掉、🔴 排最前 * * 樣本資料直接取自 leo21c live KBDB 與 InkStoneCo sprint-2026-07a.md 的真實形態 * (epoch 秒 created_at、07-04 殘 entries、含劃掉列的 markdown 表格),非憑空編造。 */ import { describe, it, expect } from 'vitest'; import { parseCreatedAtMs, taipeiDayKey, buildRouteModel, buildWaitingFallback, parseSprintWaitingTable, sortWaitingItems, cleanWaitingTitle, pickLatestSprintFiles, humanAge, type KbdbEntry, } from '../src/lib/console-dashboard-model'; // 2026-07-07 10:00 UTC(台北 18:00)當「現在」 const NOW = Date.parse('2026-07-07T10:00:00Z'); const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000); function entry(created: string, content: unknown): KbdbEntry { return { id: crypto.randomUUID(), entry_type: 'x', content: JSON.stringify(content), created_at: SEC(created) }; } describe('parseCreatedAtMs / taipeiDayKey', () => { it('吃 epoch 秒(leo21c 實測形態)、epoch 毫秒、ISO、sqlite 字串', () => { expect(parseCreatedAtMs(1783413742)).toBe(1783413742000); expect(parseCreatedAtMs(1783413742000)).toBe(1783413742000); expect(parseCreatedAtMs('2026-07-07T08:42:22Z')).toBe(Date.parse('2026-07-07T08:42:22Z')); expect(parseCreatedAtMs('2026-07-07 08:42:22')).toBe(Date.parse('2026-07-07T08:42:22Z')); expect(parseCreatedAtMs(null)).toBeNull(); }); it('台北日曆日:UTC 20:00 已是台北隔天', () => { expect(taipeiDayKey(Date.parse('2026-07-06T20:00:00Z'))).toBe('2026-07-07'); expect(taipeiDayKey(Date.parse('2026-07-06T15:00:00Z'))).toBe('2026-07-06'); }); }); describe('buildRouteModel — 今日路線不吃殘資料(stale 根因 2)', () => { it('07-04 寫入的 scope:today 任務,07-07 看不再算今日(is_today=false、today_total=0)', () => { // live 實態:dash_task 最後寫入 2026-07-04,07-07 dashboard 仍把它當「今日 2/4」 const entries = [ entry('2026-07-04T10:22:00Z', { title: 'T-loop4 檢討 routine 首跑', status: 'done', order: 1, scope: 'today' }), entry('2026-07-04T04:44:00Z', { title: 'T-cockpit 駕駛艙三件套', status: 'doing', order: 2, scope: 'today' }), entry('2026-07-04T04:44:00Z', { title: 'T-kb-skeleton 總庫 0→1', status: 'todo', order: 1, scope: 'week' }), ]; const m = buildRouteModel(entries, NOW); expect(m.is_today).toBe(false); expect(m.today_total).toBe(0); // 不假裝 07-04 的路線是今天的 expect(m.today_done).toBe(0); expect(m.tasks).toHaveLength(3); // 資料仍在(頁面降級顯示「最後路線(N 天前)」) expect(m.tasks.every((t) => !t.is_today_write)).toBe(true); expect(m.updated_ago_minutes).toBeGreaterThan(3 * 24 * 60 - 60); // ≈3 天 }); it('今天寫入的任務照常計入今日(同 title 後寫蓋前寫)', () => { const entries = [ entry('2026-07-07T09:00:00Z', { title: 'A', status: 'done', order: 1, scope: 'today' }), // 最新(DESC first) entry('2026-07-07T08:00:00Z', { title: 'B', status: 'doing', order: 2, scope: 'today' }), entry('2026-07-04T04:44:00Z', { title: 'A', status: 'todo', order: 1, scope: 'today' }), // 舊值被蓋 ]; const m = buildRouteModel(entries, NOW); expect(m.is_today).toBe(true); expect(m.today_total).toBe(2); expect(m.today_done).toBe(1); expect(m.tasks.find((t) => t.title === 'A')?.status).toBe('done'); }); it('空庫:誠實回 -1 / false,不編資料', () => { const m = buildRouteModel([], NOW); expect(m.tasks).toHaveLength(0); expect(m.is_today).toBe(false); expect(m.updated_ago_minutes).toBe(-1); }); }); describe('buildWaitingFallback — dash_wait 殘資料必標 stale(stale 根因 1)', () => { it('07-04 的 open 項在 07-07 仍列出,但 stale=true、age 正確', () => { // live 實態:等leo清單#11 已於 07-05 銷案(誤判),dash_wait 卻沒人寫 closed const entries = [ entry('2026-07-04T11:05:00Z', { title: '檢查 Claude Environment 兩個 routine trigger', status: 'open' }), entry('2026-07-04T10:41:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'closed' }), entry('2026-07-04T05:56:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'open' }), // 舊 open 被 closed 蓋 ]; const m = buildWaitingFallback(entries, NOW); expect(m.source).toBe('kbdb_dash_wait'); expect(m.items.map((i) => i.title)).toEqual(['檢查 Claude Environment 兩個 routine trigger']); expect(m.stale).toBe(true); // >24h 沒維護 → 頁面必須警示「可能過時」 expect(m.updated_ago_minutes).toBeGreaterThan(24 * 60); }); it('完全無資料 → source none + 管線未接 note', () => { const m = buildWaitingFallback([], NOW); expect(m.source).toBe('none'); expect(m.items).toHaveLength(0); expect(m.note).toContain('管線未接'); }); }); describe('parseSprintWaitingTable — 等leo清單活資料源(stale 根因 3 的解)', () => { // 取自 InkStoneCo sprint-2026-07a.md 真實形態(截短),含:劃掉列、✅ 急迫欄、🔴/🟡/⚪ const MD = `# Sprint 2026-07a ## 任務板 - [x] 東西 ## 等 leo 清單(暗待辦歸零區) | # | 事項 | 急迫 | |---|------|------| | 1 | ~~Claude Environment 補 secrets~~ ✅ 07-02 實測確認 | ⚪ 已完成 | | 3 | Telegram bot token rotate(可與 #1 一起做) | 🟡 建議今天順手 | | 9 | **credential 路徑1 不成立,SDD 需重新設計**(詳情見 Arcrun#2) | ✅ 已解,T2-T9 解凍 | | 11 | ~~cloud-worker trigger 連續兩天疑似未觸發~~ **✅ 銷案=誤判** | ✅ 銷案(誤判)+防複發已上線 | | 14 | **\`NPM_API_TOKEN\` 缺失,acr search 等 CLI 改動全數卡在 npm 未發版**(2026-07-05 核實):Environment 現只有五把 | 🟡 建議本週補 | | 13 | 🔴 **mira \`.env\` 歷史憑證外洩(2026-07-05 發現,已止血未 rotate)**:mira repo 曾把 .env 誤入 git 追蹤 | 🔴 需 leo 裁(安全事件) | | 15 | **T-cockpit③(md→Gitea Project 投影)方向選擇**(2026-07-06 spike 核實):需 leo 三選一 | 🟡 品味/方向題 | ### 下一段 別的內容`; it('已銷案列(~~ 或急迫欄 ✅/已完成)全濾掉;open 項留下、🔴 排最前', () => { const items = parseSprintWaitingTable(MD, 'sprint-2026-07a.md')!; expect(items).not.toBeNull(); expect(items.map((i) => i.id)).toEqual(['13', '3', '14', '15']); // 🔴 最前,餘依表序 expect(items[0].urgency).toBe('🔴'); expect(items[0].title).toContain('mira'); expect(items[0].sprint).toBe('sprint-2026-07a.md'); expect(items.every((i) => !i.title.includes('~~') && !i.title.includes('**'))).toBe(true); }); it('07b 形態:段落標題帶註記「(RAG 線)」、編號帶字母前綴 R1', () => { const md07b = `## 等 leo 清單(RAG 線) | # | 事項 | 急迫 | |---|------|------| | R1 | pilot 客戶挑選(1–2 家)+產品命名/定價區間(rag-wave1 T0) | 🟡 Phase 0 前置 | | R2 | ~~Gemini key~~ ✅ 已補 | ⚪ 已完成 | `; const items = parseSprintWaitingTable(md07b, 'sprint-2026-07b.md')!; expect(items.map((i) => i.id)).toEqual(['R1']); expect(items[0].urgency).toBe('🟡'); }); it('跨 sprint 合併排序:現役檔項目先傳,🔴 仍浮最上', () => { const b = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| R1 | RAG pilot | 🟡 x |', 'b.md')!; const a = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| 13 | 憑證外洩 | 🔴 x |', 'a.md')!; const merged = sortWaitingItems([...b, ...a]); expect(merged.map((i) => i.id)).toEqual(['13', 'R1']); }); it('沒有等 leo 段落 → null(caller 誠實 fallback,不硬湊)', () => { expect(parseSprintWaitingTable('# 別的檔\n沒有清單')).toBeNull(); expect(parseSprintWaitingTable('## 等 leo 清單\n(還沒建表)')).toBeNull(); }); it('cleanWaitingTitle:去粗體、長標題在「:」截斷、80 字上限', () => { expect(cleanWaitingTitle('**NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版**(07-05 核實):Environment 現只有五把')).toBe( 'NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版(07-05 核實)', ); expect(cleanWaitingTitle('短標題').length).toBeLessThan(80); }); it('cleanWaitingTitle:去開頭急迫 emoji(急迫欄已有)、截斷後不留未閉合括號', () => { // 等leo清單#13 實際形態:事項欄自帶 🔴 開頭 expect(cleanWaitingTitle('🔴 **mira `.env` 歷史憑證外洩(2026-07-05 發現)**:mira repo 曾把 .env 誤入 git')).toBe( 'mira .env 歷史憑證外洩(2026-07-05 發現)', ); // 等leo清單#3 實際形態:「:」在括號內 → 截斷會留半個「(」,要剪掉 expect(cleanWaitingTitle('Telegram bot token rotate(可與 #1 一起做:rotate 後直接填新值進 Environment)')).toBe( 'Telegram bot token rotate', ); }); }); describe('pickLatestSprintFiles / humanAge', () => { it('sprint-*.md 字典序取最新兩個(新→舊);無 sprint 檔回空陣列', () => { // live 實態(2026-07-07):07b 已開、07a 的 🔴 未銷案項仍要讀 → 兩檔都拿 expect(pickLatestSprintFiles(['sprint-2026-07a.md', 'sprint-2026-07b.md', 'tasks.md', 'routines'])).toEqual([ 'sprint-2026-07b.md', 'sprint-2026-07a.md', ]); expect(pickLatestSprintFiles(['sprint-2026-06b.md', 'sprint-2026-07a.md', 'sprint-2026-05a.md'])).toEqual([ 'sprint-2026-07a.md', 'sprint-2026-06b.md', ]); expect(pickLatestSprintFiles(['tasks.md'])).toEqual([]); }); it('humanAge 誠實顯示天級 age', () => { expect(humanAge(-1)).toBe('時間不明'); expect(humanAge(30)).toBe('30 分鐘前'); expect(humanAge(3 * 24 * 60)).toBe('3 天前'); }); }); // ── sprint 任務板(「今日完成」活資料源,fix/console-truth-audit)──────────────────── // 樣本取自 sprint-2026-07a/07b.md 真實維護慣例:`- [x]`+行內「**完成(YYYY-MM-DD…)**」、 // `- [🔄]` 進行中、`- [!]` 卡住、縮排子彈(「 - 做法:」)非任務、一行多次完成(T6-T9 分兩天)。 import { parseSprintTaskBoard, buildSprintRouteModel, cleanBoardTitle, } from '../src/lib/console-dashboard-model'; const BOARD_MD = `# Sprint 2026-07x ## 任務板(P1 > P2 > P3,worker 由上往下取) ### P1 - [ ] **T5-graph(Graph 查詢 workflow 化)**:把 neighbors/traverse 改寫成 workflow。 - 做法:在 \`Leo/arcrun-rag\` 建 workflows/graph-neighbors.yaml - 驗收:curl 原始輸出貼證據。 - [🔄] **T-cockpit 駕駛艙三件套**:①②完成(2026-07-04,[總管]),③ spike 卡方向。 - [x] RECON:git proxy 不攔 Gitea(06-30 已驗) - [x] **T-loop1d 今晚任務**:實作 console v0。**完成(2026-07-02,[cloud-worker])**:全綠。 - [x] T6-T9(07-06~08 提前):**T8/T9 完成(2026-07-04,commit 1d19d46)**;**T6/T7 完成(2026-07-05 本機總管)**。 - [!] 附帶:acr search npm 漏發——**卡住(2026-07-05,[cloud-worker])**:缺 NPM_API_TOKEN。 ### 候補區(本 sprint 不佔額度) - 短網址通用化——規劃見 content-pipeline.md ## 等 leo 清單 | # | 事項 | 急迫 | |---|------|------| | 1 | 假裝這裡有一項 | 🟡 | `; describe('parseSprintTaskBoard — 任務板解析(寬鬆容錯,解析不了寧可略過)', () => { it('只收行首 checkbox;縮排子彈/候補區敘述/等leo清單表格都不是任務', () => { const tasks = parseSprintTaskBoard(BOARD_MD, 'sprint-2026-07x.md'); expect(tasks).not.toBeNull(); expect(tasks!.map((t) => t.status)).toEqual(['todo', 'doing', 'done', 'done', 'done', 'blocked']); expect(tasks!.every((t) => t.sprint === 'sprint-2026-07x.md')).toBe(true); }); it('完成日期:抓行內所有「完成(YYYY-MM-DD」;[x] 無日期=空陣列(不冒領)', () => { const tasks = parseSprintTaskBoard(BOARD_MD)!; expect(tasks[2].completed_days).toEqual([]); // RECON:勾了但沒帶日期 → 不歸任何一天 expect(tasks[3].completed_days).toEqual(['2026-07-02']); expect(tasks[4].completed_days).toEqual(['2026-07-04', '2026-07-05']); // 一行兩次完成 expect(tasks[1].completed_days).toEqual(['2026-07-04']); // doing 項的部分完成標記也記著(狀態仍 doing) }); it('標題:優先取第一個粗體;無粗體取全形冒號前', () => { const tasks = parseSprintTaskBoard(BOARD_MD)!; expect(tasks[0].title).toBe('T5-graph(Graph 查詢 workflow 化)'); expect(tasks[2].title).toBe('RECON'); expect(cleanBoardTitle('附帶:`acr search` npm 漏發')).toBe('附帶:acr search npm 漏發'.slice(0, 80)); }); it('沒有任務板段落 → null(caller 誠實 fallback dash_task)', () => { expect(parseSprintTaskBoard('# 只有標題\n\n## 等 leo 清單\n| 1 | x | 🔴 |')).toBeNull(); }); }); describe('buildSprintRouteModel — 「今日完成」只認今天(台北)的完成標記', () => { it('NOW=07-07:07-04/07-05 完成的不算今日;開項全列(今日路線)', () => { const tasks = parseSprintTaskBoard(BOARD_MD)!; const m = buildSprintRouteModel(tasks, NOW); expect(m.today_done).toBe(0); // 板上今天沒勾任何完成 → 誠實 0 expect(m.done_today_titles).toEqual([]); // 開項:todo + doing + blocked = 3;today_total = 0 完成 + 3 開 expect(m.today_total).toBe(3); expect(m.tasks.map((t) => t.status)).toEqual(['todo', 'doing', 'blocked']); }); it('完成標記日期=今天 → 進今日完成清單;歷史完成項不出現', () => { const tasks = parseSprintTaskBoard(BOARD_MD)!; // 模擬 progress-guard 今天勾掉 T5-graph tasks[0] = { ...tasks[0], status: 'done', completed_days: [taipeiDayKey(NOW)] }; const m = buildSprintRouteModel(tasks, NOW); expect(m.today_done).toBe(1); expect(m.done_today_titles).toEqual(['T5-graph(Graph 查詢 workflow 化)']); expect(m.today_total).toBe(1 + 2); // 1 完成 + doing/blocked 2 開項 expect(m.tasks[0].status).toBe('done'); // 今日完成排最前(✓ 給 leo 看「做了什麼」) // 07-02/07-04/07-05 完成的三項(含無日期 RECON)都不在清單裡 expect(m.tasks.find((t) => t.title === 'RECON')).toBeUndefined(); }); it('跨台北午夜:昨天的完成不會被冒領成今天(快取安全性的模型面保證)', () => { const tasks = parseSprintTaskBoard(BOARD_MD)!; tasks[0] = { ...tasks[0], status: 'done', completed_days: ['2026-07-07'] }; // 台北 07-08 00:10(UTC 07-07 16:10)再看:07-07 的完成已不是「今日」 const afterMidnight = Date.parse('2026-07-07T16:10:00Z'); expect(buildSprintRouteModel(tasks, afterMidnight).today_done).toBe(0); }); });