fix(console): 真實性稽核修正——時區統一台北、精耕層數字、今日完成接 sprint 任務板
稽核輪(fix/console-truth-audit,總管派工+leo 三裁決): B1 時區:console 全站日期/時間顯示統一 Asia/Taipei(新 lib/taipei-time.ts, server 判定與頁面 client JS 同一套、等價性單測互鎖)。原本用瀏覽器本地 getter——換裝置就換日期;憑證頁改顯「日期+時分(台北)」:raw 1783349834 =台北 2026-07-06 22:57,7/6 顯示是真的,缺的是時分與固定時區。 B2 總庫數字(leo 裁:14-E 遺產 deprecated 不再顯示):總庫搜尋/設定頁頭部 統計改讀新 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入+最近 寫入,全 live、limit=1/stats 聚合不掃庫);搜尋仍搜全庫、結果 wiki_card 加「精耕」綠標;dashboard-data 的 entries 計數補 owner_id(原混租戶: 459,137 vs leo 458,732)。 B3 設定頁:vectorize 文案改具體事實(config kbdb_embed+acr update,#32 教訓)+live 狀態;新增 MCP token TTL 誠實佔位(GET /console/settings-data, 可調功能=Arcrun#19,不假裝能遠端改)。 C 今日完成/今日路線接 sprint 任務板(leo 拍板):比照 #36 等leo清單模式, 同一輪 Gitea fetch(90s CF cache 共用)解析「## 任務板」checkbox;「今日 完成」只認「完成(今天台北日)」標記,[x] 無日期不冒領;快取存原始 completed_days,跨台北午夜不誤算;板檔今天沒 commit → 頁面誠實標「今日 任務板未更新」;dash_task 降 fallback、燈號判定不變。parser 以 live 07a/07b 檔實測(標題抽取棄「首個粗體」——實讀發現會抽到完成註記)。 驗證:tsc exit 0;vitest 82/83(1 失敗=pre-existing executor「不存在的 零件」,stash 於 main 複驗同樣紅);新增 taipei-time 10 案例(含跨日/跨年/ client-server 等價/leo 實測 epoch)+任務板 parser 7 案例;兩頁 inline JS 渲染後抽 <script> node --check 全過(mistakes #24 手法)。
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* cachedGiteaWaiting 快取層測試(總管 #36 審查要求的證據)。
|
||||
* cachedGiteaSprint 快取層測試(總管 #36 審查要求的證據;fix/console-truth-audit 起
|
||||
* 同一輪快取多帶任務板 board——「等你的事」與「今日完成」共用同一次 Gitea fetch)。
|
||||
*
|
||||
* 跑在 vitest-pool-workers(workerd)裡,caches.default 是真的 Cache API——
|
||||
* 不是 mock cache 物件;fetcher 用注入的假函式計數,證明:
|
||||
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hit(fetcher 不再被打)
|
||||
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hit(fetcher 不再被打),board 一起回放
|
||||
* 2. hit 回放時 age 隨牆鐘推進(不回放停走的時間)
|
||||
* 3. fetcher 失敗(回 null)不快取——下次仍會重試,不把抖動放大成 90 秒盲區
|
||||
* 4. 缺 GITEA_BASE_URL/GITEA_TOKEN 直接 null(快取層不啟動)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cachedGiteaWaiting } from '../src/routes/console-dashboard';
|
||||
import type { WaitingModel } from '../src/lib/console-dashboard-model';
|
||||
import { cachedGiteaSprint } from '../src/routes/console-dashboard';
|
||||
import type { SprintSnapshot } from '../src/lib/console-dashboard-model';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
function fakeEnv(repo: string): Bindings {
|
||||
@@ -22,13 +23,19 @@ function fakeEnv(repo: string): Bindings {
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
function model(): WaitingModel {
|
||||
function snapshot(): SprintSnapshot {
|
||||
return {
|
||||
items: [{ title: 'mira .env 歷史憑證外洩', id: '13', urgency: '🔴', sprint: 'sprint-2026-07a.md' }],
|
||||
source: 'gitea_sprint',
|
||||
updated_ago_minutes: 10,
|
||||
stale: false,
|
||||
sprint_files: ['sprint-2026-07b.md', 'sprint-2026-07a.md'],
|
||||
waiting: {
|
||||
items: [{ title: 'mira .env 歷史憑證外洩', id: '13', urgency: '🔴', sprint: 'sprint-2026-07a.md' }],
|
||||
source: 'gitea_sprint',
|
||||
updated_ago_minutes: 10,
|
||||
stale: false,
|
||||
sprint_files: ['sprint-2026-07b.md', 'sprint-2026-07a.md'],
|
||||
},
|
||||
board: [
|
||||
{ title: 'T5-graph(Graph 查詢 workflow 化)', status: 'todo', completed_days: [], sprint: 'sprint-2026-07b.md' },
|
||||
{ title: 'T-kb2 07-07', status: 'done', completed_days: ['2026-07-05'], sprint: 'sprint-2026-07a.md' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,37 +45,40 @@ function collector() {
|
||||
return { waitUntil: (p: Promise<unknown>) => pending.push(p), flush: () => Promise.all(pending) };
|
||||
}
|
||||
|
||||
describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => {
|
||||
it('第一次 miss 打 fetcher;90s 內第二次 hit、fetcher 不再被打', async () => {
|
||||
describe('cachedGiteaSprint — Gitea 讀取快取(TTL 90s,waiting + board 同輪)', () => {
|
||||
it('第一次 miss 打 fetcher;90s 內第二次 hit、fetcher 不再被打,board 一起回放', async () => {
|
||||
const env = fakeEnv('Leo/cache-test-1');
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls++;
|
||||
return model();
|
||||
return snapshot();
|
||||
};
|
||||
const c = collector();
|
||||
const t0 = Date.now();
|
||||
|
||||
const r1 = await cachedGiteaWaiting(env, t0, c.waitUntil, fetcher);
|
||||
const r1 = await cachedGiteaSprint(env, t0, c.waitUntil, fetcher);
|
||||
expect(r1?.cache).toBe('miss');
|
||||
expect(calls).toBe(1);
|
||||
await c.flush(); // cache.put 落地
|
||||
|
||||
const r2 = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, fetcher);
|
||||
const r2 = await cachedGiteaSprint(env, t0 + 60_000, c.waitUntil, fetcher);
|
||||
expect(r2?.cache).toBe('hit'); // ← 快取生效的客觀證據
|
||||
expect(calls).toBe(1); // fetcher 沒有被第二次呼叫=Gitea 沒被打
|
||||
expect(r2?.items[0].id).toBe('13');
|
||||
expect(r2?.waiting.items[0].id).toBe('13');
|
||||
// 任務板同輪回放:存的是原始 completed_days(「今天完成幾件」由請求當下算,跨午夜不冒領)
|
||||
expect(r2?.board?.length).toBe(2);
|
||||
expect(r2?.board?.[1].completed_days).toEqual(['2026-07-05']);
|
||||
});
|
||||
|
||||
it('hit 回放時 age 隨牆鐘推進(存 10 分鐘前、過 60s 讀 → 11 分鐘前),不停走', async () => {
|
||||
const env = fakeEnv('Leo/cache-test-2');
|
||||
const c = collector();
|
||||
const t0 = Date.now();
|
||||
await cachedGiteaWaiting(env, t0, c.waitUntil, async () => model());
|
||||
await cachedGiteaSprint(env, t0, c.waitUntil, async () => snapshot());
|
||||
await c.flush();
|
||||
const r = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, async () => model());
|
||||
const r = await cachedGiteaSprint(env, t0 + 60_000, c.waitUntil, async () => snapshot());
|
||||
expect(r?.cache).toBe('hit');
|
||||
expect(r?.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift
|
||||
expect(r?.waiting.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift
|
||||
});
|
||||
|
||||
it('fetcher 失敗(null)不快取:下一次仍重打 fetcher', async () => {
|
||||
@@ -79,9 +89,9 @@ describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => {
|
||||
calls++;
|
||||
return null;
|
||||
};
|
||||
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
await c.flush();
|
||||
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
expect(calls).toBe(2); // 失敗沒有被 90 秒快取住
|
||||
});
|
||||
|
||||
@@ -90,10 +100,10 @@ describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => {
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls++;
|
||||
return model();
|
||||
return snapshot();
|
||||
};
|
||||
const c = collector();
|
||||
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, fetcher)).toBeNull();
|
||||
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, fetcher)).toBeNull();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,3 +192,98 @@ describe('pickLatestSprintFiles / humanAge', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* lib/taipei-time.ts 測試(fix/console-truth-audit):
|
||||
* 1. server 版跨日案例——UTC 傍晚已是台北隔天(時區 bug 的核心場景)。
|
||||
* 2. client 版(TAIPEI_CLIENT_JS,內嵌進 console 頁的 ES5 字串)用 new Function 求值,
|
||||
* 與 server 版逐案對照——兩份實作漂移會直接紅,這是「同一套 helper」的客觀證據。
|
||||
* 3. leo 實測案例:leo21c /credentials 的 gitea_token created_at=1783349834(epoch 秒)
|
||||
* → 台北 2026-07-06 22:57(顯示 7/6 是真的,但原本只顯示日期且吃裝置時區——本修加時分固定台北)。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TAIPEI_CLIENT_JS,
|
||||
taipeiDayKey,
|
||||
taipeiDateStr,
|
||||
taipeiDateTimeStr,
|
||||
taipeiTimeStr,
|
||||
} from '../src/lib/taipei-time';
|
||||
|
||||
// client 版求值:跟頁面一樣把字串當 JS 跑起來,拿到同名函式
|
||||
const client = new Function(
|
||||
`${TAIPEI_CLIENT_JS}; return { taipeiDayKey, taipeiDateStr, taipeiDateTimeStr, taipeiTimeStr, taipeiMonthDay };`,
|
||||
)() as {
|
||||
taipeiDayKey: (ms: number) => string;
|
||||
taipeiDateStr: (ms: number) => string;
|
||||
taipeiDateTimeStr: (ms: number) => string;
|
||||
taipeiTimeStr: (ms: number) => string;
|
||||
taipeiMonthDay: (ms: number) => { month: number; day: number };
|
||||
};
|
||||
|
||||
describe('taipei-time — server 版', () => {
|
||||
it('UTC 16:30 已是台北隔天 00:30(跨日核心案例)', () => {
|
||||
const ms = Date.parse('2026-07-06T16:30:00Z');
|
||||
expect(taipeiDayKey(ms)).toBe('2026-07-07');
|
||||
expect(taipeiDateTimeStr(ms)).toBe('2026-07-07 00:30');
|
||||
});
|
||||
|
||||
it('UTC 15:59 仍是台北同日 23:59(跨日邊界的另一側)', () => {
|
||||
const ms = Date.parse('2026-07-06T15:59:00Z');
|
||||
expect(taipeiDayKey(ms)).toBe('2026-07-06');
|
||||
expect(taipeiDateTimeStr(ms)).toBe('2026-07-06 23:59');
|
||||
});
|
||||
|
||||
it('跨月/跨年也對:UTC 12-31 20:00 → 台北 01-01 04:00', () => {
|
||||
const ms = Date.parse('2026-12-31T20:00:00Z');
|
||||
expect(taipeiDateStr(ms)).toBe('2027-01-01');
|
||||
expect(taipeiTimeStr(ms)).toBe('04:00:00');
|
||||
});
|
||||
|
||||
it('leo 實測值:credentials created_at=1783349834(epoch 秒)→ 台北 2026-07-06 22:57', () => {
|
||||
expect(taipeiDateTimeStr(1783349834 * 1000)).toBe('2026-07-06 22:57');
|
||||
// 同一時刻 UTC 是 14:57——若顯示端誤用 UTC 或別的裝置時區,日期/時間就會漂
|
||||
expect(new Date(1783349834 * 1000).toISOString().slice(0, 16)).toBe('2026-07-06T14:57');
|
||||
});
|
||||
});
|
||||
|
||||
describe('taipei-time — client 版(TAIPEI_CLIENT_JS)與 server 版等價', () => {
|
||||
const cases = [
|
||||
Date.parse('2026-07-06T16:30:00Z'), // 跨日
|
||||
Date.parse('2026-07-06T15:59:59Z'), // 跨日邊界前一秒
|
||||
Date.parse('2026-12-31T20:00:00Z'), // 跨年
|
||||
Date.parse('2026-02-28T17:00:00Z'), // 跨月(非閏年 2 月)
|
||||
1783349834 * 1000, // leo 實測 credential created_at
|
||||
0, // epoch 起點(1970-01-01 08:00 台北)
|
||||
];
|
||||
|
||||
it('taipeiDayKey / DateStr / DateTimeStr / TimeStr 全案例一致', () => {
|
||||
for (const ms of cases) {
|
||||
expect(client.taipeiDayKey(ms)).toBe(taipeiDayKey(ms));
|
||||
expect(client.taipeiDateStr(ms)).toBe(taipeiDateStr(ms));
|
||||
expect(client.taipeiDateTimeStr(ms)).toBe(taipeiDateTimeStr(ms));
|
||||
expect(client.taipeiTimeStr(ms)).toBe(taipeiTimeStr(ms));
|
||||
}
|
||||
});
|
||||
|
||||
it('taipeiMonthDay(頁首中文日期用):跨日案例回台北的月/日', () => {
|
||||
expect(client.taipeiMonthDay(Date.parse('2026-07-06T16:30:00Z'))).toEqual({ month: 7, day: 7 });
|
||||
expect(client.taipeiMonthDay(Date.parse('2026-12-31T20:00:00Z'))).toEqual({ month: 1, day: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user