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:
@@ -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/map(cypher 代轉慣例)+逐庫詳圖 /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/R4,console 首頁藏書地圖資料源)
|
||||
*
|
||||
* 驗證 IO 接線(聚合真身在 KBDB 基本盤 kbdb/src/routes/map.ts,M2 已測;這裡只測轉發):
|
||||
* 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 假 host(wrangler.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 () => {
|
||||
// 不掛 interceptor:disableNetConnect 下 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 不可達');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user