/**
 * 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：勾了但沒帶日期 → 不歸任何一天
    // [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-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);
  });
});

// ── 2026-07-08 live bug 重現（fix/console-today-write-dayline）───────────────────
// 實況：sprint-2026-07b.md 台北 07-08 05:50（＝UTC 07-07 21:50，cloud-worker 跨 UTC
// 日界寫入）勾了 CP1/T7/T5-provider，dashboard 卻回 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 > P3，worker 由上往下取）

### P1 不卡框架、可無人值守獨立跑（明早可直接開工）

- [🔄→只剩1042] **T5-graph（Graph 查詢 workflow 化）**：把 kbdb-graph-plugin 的 neighbors/traverse 改寫成 **arcrun workflow**。
  - 做法：在 \`Leo/arcrun-rag\` 建 \`workflows/graph-neighbors.yaml\`。
- [x] **T5-provider（LLM 換源 Gemini recipe）**：精耕/圖萃取要能換 provider。
  - **\`acr run\` 端到端驗過（[cloud-worker] 2026-07-07，\`arcrun-rag\` commit \`8b41dca\`）**：真回生成文字。**✅ 部署驗收完成（2026-07-08 總管）**：production 同步查詢回真 Gemini 生成文字。
- [x] **CP1（content-pipeline 多出口架構驗證）**：另一條產品線。
  - **完成（[cloud-worker] 2026-07-07，\`content-pipeline\` commit \`3376322\`）**：T1＋T2 皆完成。零回歸。
  - 驗收：**硬約束＝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('CP1（content-pipeline 多出口架構驗證）')?.completed_days).toContain('2026-07-07');
    expect(byTitle.get('T5-provider（LLM 換源 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:50：cloud-worker 的 UTC 日期戳算進台北今天', () => {
    const tasks = parseSprintTaskBoard(BOARD_07B_MD)!;
    // 台北 2026-07-08 21:40（＝UTC 13:40，live 實測時刻）：三個今天勾的 [x] 都要進今日完成
    const m = buildSprintRouteModel(tasks, Date.parse('2026-07-08T13:40:00Z'));
    expect(m.done_today_titles).toEqual(
      expect.arrayContaining([
        'T5-provider（LLM 換源 Gemini recipe）',
        'CP1（content-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);
  });
});
