99da35a885
leo 抱怨「等你的事」錯了好幾天、首頁總覽對不上實際進度。診斷結論: - dash_wait 沒有活的維護管線(最後寫入 07-04;等leo清單#11 已於 07-05 銷案=誤判,dashboard 卻繼續掛著)——真相源其實是 InkStoneCo sprint 檔 「## 等 leo 清單」(progress-guard 每日核實維護) - dash_task 的 scope:today 沒有日期,07-04 的路線到 07-07 還被當今天的 - 燈號吃到 stale 任務;系統狀況/總覽無 live 信號 修法: - 等你的事:首選 Gitea sprint 等leo清單(合併最新兩檔——07b 開了、🔴 仍 掛 07a;需 GITEA_TOKEN secret);讀不到 fallback dash_wait 必標 age+stale - 今日路線:台北日曆日判 is_today,非今日寫入降級「最後路線(N 天前)」 - 燈號只吃今日寫入任務+心跳+KBDB /health,附 light_reason - 新增系統狀況(KBDB /health、embed enabled:false 誠實顯示、graph /triplets/stats、workflow 數)與總庫規模(entries/wiki_card/triplets)全 live - 聚合判定抽純函式 lib/console-dashboard-model.ts,vitest 15 案例 (含真實 sprint 檔形態:劃掉列/✅ 急迫欄/R1 字母編號/🔴 排序) 驗證:tsc --noEmit 0;vitest 65/66(1 失敗為 main 既有 executor.test.ts, stash 復原後同樣失敗,非本次引入);parser 對 live sprint-2026-07a/07b dry-run 撈出 🔴#13 等 9 項 open、清單維護於 31 分鐘前。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
/**
|
||
* 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 天前');
|
||
});
|
||
});
|