Files
Arcrun/cypher-executor/tests/console-dashboard-model.test.ts
T
Leo 88633b0da4 fix(console): 駕駛艙「今日完成」跨 UTC 日界修正——07b 板實態 3 個 parser 落差(0/10 假空)
SDD:system-dev/docs/3-specs/arcrun/search-console/tasks.md task 5.6(本次新增,Phase 5 稽核輪延伸)

live bug(2026-07-08 21:40 台北實測):sprint-2026-07b.md 台北今晨 05:50
(=UTC 07-07 21:50,cloud-worker 跨 UTC 日界寫入)勾了 CP1/T7/T5-provider,
/console/dashboard-data 卻回 today_done:0/today_total:10、done_today_titles:[]。

根因=任務板 parser 與 07b 實際維護慣例的三個落差(lib/console-dashboard-model.ts):
1. COMPLETED_DAY 要求日期緊貼「完成(」——07b 起署名在日期前
   (「完成([cloud-worker] 2026-07-07,…)」)→ T7 的完成日抓不到,
   [x] 無日期=整項蒸發(不冒領原則誤傷)。
2. 完成標記寫在縮排子彈上(CP1/T5-provider 的任務行只有 [x])→ 舊 parser
   只掃行首 checkbox 行 → 同樣蒸發。
3. cloud-worker sandbox 時鐘是 UTC、routine 排程台北清晨(06:30=UTC 前一日
   22:30)——它戳的日期 D 其實是台北 D+1 凌晨的完成,todayKey 永遠對不上。

修法(全在純函式層,route 零改動):
- DONE_STAMP 改抓「完成(…)」括號內容、日期允許前綴;縮排補充行的完成
  標記歸到所屬任務(子彈本身仍不是任務)。
- [cloud-worker] 署名戳 D 展開台北候選日 {D, D+1};progress-guard 跑台北
  16:30(UTC 同日)不展開——展開只會多冒領;本機/總管台北戳照舊精確,
  「昨天完成的明天不冒領」不變。
- 狀態欄放寬:[🔄→只剩1042](07b 實例)認 doing,任務不再蒸發、today_total
  不再少算;x 帶註記仍不冒領 done(寬鬆容錯原則不變)。

驗證:新增 5 案例(07b 逐字真實形態)修前紅(today_done:0、done_today_titles
[]=live 症狀重現)修後綠;model 測試 27/27;全套 vitest 87/88(1 失敗=
pre-existing executor「不存在的零件」,stash 於未改樹複驗同樣紅);tsc 錯誤
行數與未改樹相同(12 行,全在 validate/webhooks-named,workers-types 環境
漂移,非本次引入);另用 Gitea live 07a+07b 檔直灌新 parser:today_done:3、
done_today_titles=[T5-provider, CP1, T7]=板上真相。不部署(留 task 5.5)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:51:24 +08:00

384 lines
21 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.
/**
* console 駕駛艙聚合純函式測試(fix/console-dashboard-live-data2026-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-0407-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 殘資料必標 stalestale 根因 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 段落 → nullcaller 誠實 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 > P3worker 由上往下取)
### P1
- [ ] **T5-graphGraph 查詢 workflow 化)**:把 neighbors/traverse 改寫成 workflow。
- 做法:在 \`Leo/arcrun-rag\` 建 workflows/graph-neighbors.yaml
- 驗收:curl 原始輸出貼證據。
- [🔄] **T-cockpit 駕駛艙三件套**:①②完成(2026-07-04[總管]),③ spike 卡方向。
- [x] RECONgit proxy 不攔 Gitea06-30 已驗)
- [x] **T-loop1d 今晚任務**:實作 console v0。**完成(2026-07-02[cloud-worker]**:全綠。
- [x] T6-T907-06~08 提前):**T8/T9 完成(2026-07-04commit 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:勾了但沒帶日期 → 不歸任何一天
// [cloud-worker] 署名=UTC 時鐘寫手 → 戳 D 展開台北候選日 {D, D+1}(跨日界修正)
expect(tasks[3].completed_days).toEqual(['2026-07-02', '2026-07-03']);
expect(tasks[4].completed_days).toEqual(['2026-07-04', '2026-07-05']); // 一行兩次完成(無 UTC 署名不展開)
expect(tasks[1].completed_days).toEqual(['2026-07-04']); // doing 項的部分完成標記也記著([總管]=台北戳不展開)
});
it('標題:優先取第一個粗體;無粗體取全形冒號前', () => {
const tasks = parseSprintTaskBoard(BOARD_MD)!;
expect(tasks[0].title).toBe('T5-graphGraph 查詢 workflow 化)');
expect(tasks[2].title).toBe('RECON');
expect(cleanBoardTitle('附帶:`acr search` npm 漏發')).toBe('附帶:acr search npm 漏發'.slice(0, 80));
});
it('沒有任務板段落 → nullcaller 誠實 fallback dash_task', () => {
expect(parseSprintTaskBoard('# 只有標題\n\n## 等 leo 清單\n| 1 | x | 🔴 |')).toBeNull();
});
});
describe('buildSprintRouteModel — 「今日完成」只認今天(台北)的完成標記', () => {
it('NOW=07-0707-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 3today_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-graphGraph 查詢 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:10UTC 07-07 16:10)再看:07-07 的完成已不是「今日」
const afterMidnight = Date.parse('2026-07-07T16:10:00Z');
expect(buildSprintRouteModel(tasks, afterMidnight).today_done).toBe(0);
});
});
// ── 2026-07-08 live bug 重現(fix/console-today-write-dayline)───────────────────
// 實況:sprint-2026-07b.md 台北 07-08 05:50(=UTC 07-07 21:50cloud-worker 跨 UTC
// 日界寫入)勾了 CP1/T7/T5-providerdashboard 卻回 today_done:0/today_total:10、
// done_today_titles:[]。三個 parser 落差(樣本=07b 檔逐字真實形態,非編造):
// 1. T7 的完成標記「完成([cloud-worker] 2026-07-07,…)」——07b 起署名在日期前,
// 舊 regex 要求日期緊貼「完成(」→ 抓不到 → [x] 無日期=整項蒸發。
// 2. CP1/T5-provider 的完成標記寫在縮排子彈(任務行只有 [x])→ 舊 parser 只掃行首
// checkbox 行 → 同樣蒸發。
// 3. cloud-worker 的 sandbox 時鐘是 UTC、routine 排程台北清晨——它的日期戳 07-07
// 其實是台北 07-08 凌晨的完成,todayKey(台北 07-08)永遠對不上。
const BOARD_07B_MD = `# Sprint 2026-07b
## 任務板(P1 > P2 > P3worker 由上往下取)
### P1 不卡框架、可無人值守獨立跑(明早可直接開工)
- [🔄→只剩1042] **T5-graphGraph 查詢 workflow 化)**:把 kbdb-graph-plugin 的 neighbors/traverse 改寫成 **arcrun workflow**。
- 做法:在 \`Leo/arcrun-rag\` 建 \`workflows/graph-neighbors.yaml\`。
- [x] **T5-providerLLM 換源 Gemini recipe**:精耕/圖萃取要能換 provider。
- **\`acr run\` 端到端驗過([cloud-worker] 2026-07-07\`arcrun-rag\` commit \`8b41dca\`**:真回生成文字。**✅ 部署驗收完成(2026-07-08 總管)**production 同步查詢回真 Gemini 生成文字。
- [x] **CP1content-pipeline 多出口架構驗證)**:另一條產品線。
- **完成([cloud-worker] 2026-07-07\`content-pipeline\` commit \`3376322\`**T1T2 皆完成。零回歸。
- 驗收:**硬約束=197 篇既有原稿跑 publish,blog 產物與擴展前逐檔一致(無回歸)**。
### P2 卡框架(等 Arcrun#24/#25 SDD 回來才動)
- [ ] **T1 Portal 拆分**Arcrun#24[→Arcrun]):獨立搜尋面。
### P3 本 repo 可做但非明早首選(有依賴或量體大)
- [x] **T7 golden questions 框架**:模板先行。**完成([cloud-worker] 2026-07-07\`arcrun-rag\` commit \`3d44f0a\`**:模板含題目表。
- [ ] **P1-spike 地端**miniflare 起 kbdb+cypher。
## 等 leo 清單(RAG 線)
| # | 事項 | 急迫 |
|---|------|------|
| R1 | pilot 客戶挑選 | 🟡 x |
`;
describe('07b 跨 UTC 日界 live bug(今天勾的 [x] 不得判成非今天)', () => {
it('parser:署名在日期前的完成標記抓得到;縮排子彈上的完成標記歸到所屬任務', () => {
const tasks = parseSprintTaskBoard(BOARD_07B_MD, 'sprint-2026-07b.md')!;
const byTitle = new Map(tasks.map((t) => [t.title, t]));
// 1. T7:標記在任務行上、但署名 [cloud-worker] 在日期前
expect(byTitle.get('T7 golden questions 框架')?.completed_days).toContain('2026-07-07');
// 2. CP1 / T5-provider:標記在縮排子彈上
expect(byTitle.get('CP1content-pipeline 多出口架構驗證)')?.completed_days).toContain('2026-07-07');
expect(byTitle.get('T5-providerLLM 換源 Gemini recipe')?.completed_days).toContain('2026-07-08');
// 縮排子彈本身仍不是任務(「做法:」「驗收:」行沒被誤收)
expect(tasks.some((t) => t.title.startsWith('做法') || t.title.startsWith('驗收'))).toBe(false);
});
it('parser:狀態字元帶註記([🔄→只剩1042])不再讓任務蒸發,仍是 doing;x 帶註記不冒領 done', () => {
const tasks = parseSprintTaskBoard(BOARD_07B_MD)!;
expect(tasks.find((t) => t.title.startsWith('T5-graph'))?.status).toBe('doing');
// 不誤判成完成的寬鬆容錯不變:x 後帶註記=看不懂 → 略過,不冒領 done
const weird = parseSprintTaskBoard('## 任務板\n- [x?] 半勾的東西\n- [ ] 正常項')!;
expect(weird.map((t) => t.title)).toEqual(['正常項']);
});
it('UTC 昨天 21:50=台北今天 05:50cloud-worker 的 UTC 日期戳算進台北今天', () => {
const tasks = parseSprintTaskBoard(BOARD_07B_MD)!;
// 台北 2026-07-08 21:40(=UTC 13:40live 實測時刻):三個今天勾的 [x] 都要進今日完成
const m = buildSprintRouteModel(tasks, Date.parse('2026-07-08T13:40:00Z'));
expect(m.done_today_titles).toEqual(
expect.arrayContaining([
'T5-providerLLM 換源 Gemini recipe',
'CP1content-pipeline 多出口架構驗證)',
'T7 golden questions 框架',
]),
);
expect(m.today_done).toBe(3);
expect(m.today_total).toBe(3 + 3); // 3 完成 + 開項(T5-graph doing、T1 todo、P1-spike todo
// 台北 07-08 05:55(=UTC 07-07 21:55,剛寫完當下)看也一樣算今天
expect(buildSprintRouteModel(tasks, Date.parse('2026-07-07T21:55:00Z')).today_done).toBe(3);
});
it('不冒領邊界:台北 07-09 看,07-07/07-08 的完成全部不再是今日', () => {
const tasks = parseSprintTaskBoard(BOARD_07B_MD)!;
const m = buildSprintRouteModel(tasks, Date.parse('2026-07-09T04:00:00Z')); // 台北 07-09 12:00
expect(m.today_done).toBe(0);
expect(m.done_today_titles).toEqual([]);
});
it('本機/總管的台北日期戳不受 UTC 展開影響(昨天完成的明天不冒領)', () => {
const md = '## 任務板\n- [x] **T-local**:本機做的。**完成(2026-07-07[總管]派本機 subagent**:全綠。';
const tasks = parseSprintTaskBoard(md)!;
expect(tasks[0].completed_days).toEqual(['2026-07-07']); // 不展開 +1 日
expect(buildSprintRouteModel(tasks, Date.parse('2026-07-08T13:40:00Z')).today_done).toBe(0);
});
});