feat(console): library-map M5 — 總庫搜尋頁藏書地圖首頁區塊(R4)

library-map SDD M5(Arcrun#39):console 首頁從空白搜尋框變全館地圖。

落點裁定:console 是 hash-routing 單頁殼,home 隨 profile(full=駕駛艙、
rag=總庫搜尋)——地圖放總庫搜尋頁搜尋框上方:rag profile 該頁即首頁;
full profile 它是全館入口,「點庫卡片→進該庫搜尋」動線同頁最短,一份實作
兩 profile 全吃。

- console.ts:藏書地圖區塊(lm-section)——每庫一張卡(庫名+narrative+
  top entities 前 5 帶 degree+triplet_count+commit_hash/updated 更新時間感)、
  跨庫 bridges 有資料才列一小區;兩段式渲染(全館列表先出卡,逐庫詳圖補
  degree/bridges,詳圖失敗維持名字版不擋);點庫卡片=設 library filter
  (/kbdb/search 既有 library 參數,不新造)進庫搜尋,附清除鈕搜回全館;
  /map 空陣列或錯誤=誠實空狀態(指引 POST /map/recompute backfill),
  不擋搜尋等其他功能。品牌沿 CONSOLE_BRAND 變數(#21),零外部庫。
- kbdb-proxy.ts:GET /kbdb/map+/kbdb/map/:library 純轉發(rule 07,聚合真身
  在 kbdb base M2)。owner_id 不強制注入只選擇性透傳(同 M4 MCP kbdb_get_map
  語意;現行 backfill map block owner=NULL,強制注入租戶=首頁永遠假空狀態);
  仍要求 X-Arcrun-API-Key 同閘;KBDB 不可達誠實 502。
- 測試:kbdb-map-proxy.test.ts 9 測(租戶閘/轉發/owner 透傳/空陣列/404/502)+
  console-library-map-page.test.ts 6 測(route 200/區塊在搜尋框上方/資料源接線/
  library 參數/誠實空狀態文案/品牌變數)——15/15 過。
- 誠實回報:tsc 16 行 ExecutionContext.tracing 錯誤與 executor.test 1 失敗=
  main 基線既有(git stash 對照實證),非本分支引入。

merge 後需 gated redeploy cypher-executor(leo 閘)。關聯 #39。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
This commit is contained in:
Claude
2026-07-19 09:34:28 +00:00
parent 289cd495b4
commit 10dda9e6f5
5 changed files with 369 additions and 3 deletions
@@ -0,0 +1,57 @@
/**
* GET /console 頁面測試 — 藏書地圖首頁區塊(library-map SDD M5/R4
*
* console 是單檔 HTML 薄殼(server 端零資料請求,client JS 打 /kbdb/map),所以頁面測試
* 驗的是「殼有沒有長對」:route 200、地圖區塊在搜尋框上方、進庫搜尋接的是既有 library
* 參數、空狀態/錯誤誠實文案在(不擋其他功能)。轉發行為真身在 kbdb-map-proxy.test.ts。
*/
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
async function page(): Promise<{ status: number; html: string }> {
const res = await SELF.fetch('http://localhost/console');
return { status: res.status, html: await res.text() };
}
describe('GET /console — 藏書地圖區塊', () => {
it('route 200、HTML 含地圖區塊(lm-section/lm-cards/lm-bridges', async () => {
const { status, html } = await page();
expect(status).toBe(200);
expect(html).toContain('藏書地圖');
expect(html).toContain('id="lm-section"');
expect(html).toContain('id="lm-cards"');
expect(html).toContain('id="lm-bridges"');
});
it('地圖區塊在搜尋框上方(DOM 順序:lm-section 先於 se-q', async () => {
const { html } = await page();
expect(html.indexOf('id="lm-section"')).toBeGreaterThan(-1);
expect(html.indexOf('id="lm-section"')).toBeLessThan(html.indexOf('id="se-q"'));
});
it('資料源接 /kbdb/mapcypher 代轉慣例)+逐庫詳圖 /kbdb/map/', async () => {
const { html } = await page();
expect(html).toContain("fetch('/kbdb/map'");
expect(html).toContain("'/kbdb/map/' + encodeURIComponent");
});
it('點庫卡片=既有搜尋 library 參數進庫(不新造參數)', async () => {
const { html } = await page();
expect(html).toContain("'&library=' + encodeURIComponent(S.library)");
expect(html).toContain('data-lib');
});
it('空狀態/錯誤分支=誠實文案(recompute backfill 指引)且不擋搜尋', async () => {
const { html } = await page();
expect(html).toContain('還沒有藏書地圖');
expect(html).toContain('/map/recompute');
expect(html).toContain('不影響下方搜尋');
expect(html).toContain('地圖服務不可達');
});
it('品牌字樣走 CONSOLE_BRAND 變數(未設=預設 Arcrun),地圖區塊沒硬編品牌', async () => {
const { html } = await page();
// 測試環境未設 CONSOLE_BRAND → 預設 Arcrun#21 rebrand:字樣由變數注入,非硬編 Mira)
expect(html).toContain('<title>Arcrun Console</title>');
});
});
@@ -0,0 +1,134 @@
/**
* GET /kbdb/map /kbdb/map/:library proxy 測試(library-map SDD M5/R4console 首頁藏書地圖資料源)
*
* 驗證 IO 接線(聚合真身在 KBDB 基本盤 kbdb/src/routes/map.tsM2 已測;這裡只測轉發):
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB(與本 proxy 其他端點同閘)
* 2. 轉發:/kbdb/map → base /map/kbdb/map/:library → base /map/:library(庫名 URL-encode
* 3. owner_id **不強制注入、只選擇性透傳**(與 MCP kbdb_get_map 同義)——現行 backfill 的
* map block owner_id=NULL,強制注入租戶會讓首頁永遠假空狀態
* 4. 空陣列原樣透傳(前端誠實空狀態的資料依據);404/KBDB 不可達 → 誠實回報不假裝
*
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——測試絕不外連。
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('GET /kbdb/map — 租戶閘', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/map');
expect(res.status).toBe(401);
});
it('/kbdb/map/:library 同閘 → 401', async () => {
const res = await SELF.fetch('http://localhost/kbdb/map/kb');
expect(res.status).toBe(401);
});
});
describe('GET /kbdb/map — 全館地圖轉發', () => {
it('轉發 base /map、回應原樣透傳(不注入 owner_id', async () => {
const payload = {
success: true,
libraries: [
{ library: 'kb', narrative: 'leo 的知識庫主庫', top_entities: ['00-INDEX'], triplet_count: 111, updated_at: 1784451880 },
],
count: 1,
};
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/map', method: 'GET' })
.reply(200, payload);
const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
expect(res.status).toBe(200);
const data = (await res.json()) as typeof payload;
expect(data.count).toBe(1);
expect(data.libraries[0].library).toBe('kb');
expect(data.libraries[0].triplet_count).toBe(111);
});
it('空陣列原樣透傳(前端據此顯示誠實空狀態,指引 recompute backfill', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/map', method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: unknown[]; count: number };
expect(data.libraries).toEqual([]);
expect(data.count).toBe(0);
});
it('caller 帶 owner_id → 透傳給 base(選擇性,非強制注入)', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/map', query: { owner_id: 'someone' }, method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
const res = await SELF.fetch('http://localhost/kbdb/map?owner_id=someone', { headers: KEY });
expect(res.status).toBe(200);
});
});
describe('GET /kbdb/map/:library — 單庫詳圖轉發', () => {
it('轉發 base /map/:library、詳圖(degree/bridges/commit_hash)原樣透傳', async () => {
const map = {
record_id: 'e_1',
library: 'kb',
narrative: 'leo 的知識庫主庫',
top_entities: [{ name: '00-INDEX', degree: 29 }],
relation_profile: [{ predicate: '連結至', count: 48 }],
bridges: [{ entity: 'Gitea', libraries: ['kb', 'notes'] }],
triplet_count: 111,
commit_hash: 'abc1234def',
status: 'active',
updated_at: 1784451880,
};
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/map/kb', method: 'GET' })
.reply(200, { success: true, map });
const res = await SELF.fetch('http://localhost/kbdb/map/kb', { headers: KEY });
expect(res.status).toBe(200);
const data = (await res.json()) as { map: typeof map };
expect(data.map.top_entities[0].degree).toBe(29);
expect(data.map.bridges[0].libraries).toEqual(['kb', 'notes']);
});
it('庫名 URL-encode 轉發(中文庫名不炸)', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: `/map/${encodeURIComponent('筆記庫')}`, method: 'GET' })
.reply(200, { success: true, map: { library: '筆記庫' } });
const res = await SELF.fetch(`http://localhost/kbdb/map/${encodeURIComponent('筆記庫')}`, { headers: KEY });
expect(res.status).toBe(200);
});
it('base 404(庫沒地圖)→ 原樣透傳 404,不假裝有資料', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/map/nope', method: 'GET' })
.reply(404, { success: false, error: 'not found' });
const res = await SELF.fetch('http://localhost/kbdb/map/nope', { headers: KEY });
expect(res.status).toBe(404);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(false);
});
});
describe('KBDB 不可達 → 誠實 502(前端顯示地圖不可達,不擋搜尋)', () => {
it('/kbdb/map fetch 炸 → 502 + success:false', async () => {
// 不掛 interceptordisableNetConnect 下 fetch 直接 throw → route catch → 502
const res = await SELF.fetch('http://localhost/kbdb/map', { headers: KEY });
expect(res.status).toBe(502);
const data = (await res.json()) as { success: boolean; error: string };
expect(data.success).toBe(false);
expect(data.error).toContain('KBDB 不可達');
});
});