// library-map（藏書地圖）M1+M2 — SDD system-dev/docs/3-specs/library-map（源頭 Arcrun#39）。
// 測試策略：聚合 SQL（degree 排序／predicate 統計／跨庫 join／supersede 查找）用「真 SQLite」驗——
// node:sqlite（Node ≥22.5 內建，零新依賴）跑 migrations/0001+0003 原檔，比 capture-DB 只驗 SQL
// 形狀更硬；route 行為（參數解析／400/404／回應形狀）走 Hono app.request，與既有測試同款。
// 註：D1 語意與 SQLite 幾乎同源，僅 session/consistency 層不同——本測試覆蓋的純 SQL 聚合在兩邊等價。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { mapRoutes } from '../src/routes/map';
import {
  recomputeLibraryMap,
  listLibraryMaps,
  getLibraryMapDetail,
  ensureTripletLibrarySlot,
  ensureFreshLibraryMaps,
  LIBRARY_MAP_SLOTS,
} from '../src/actions/library-map';
import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
import { createEntry } from '../src/actions/entry-crud';
import type { Bindings } from '../src/types';

// ── node:sqlite → D1 介面最小 adapter（prepare/bind/all/first/run，本 codebase 只用這些）──
function makeSqliteD1(): D1Database {
  const raw = new DatabaseSync(':memory:');
  raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
  raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8'));
  function stmt(sql: string, params: unknown[]) {
    const s = {
      bind(...args: unknown[]) { return stmt(sql, args); },
      async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; },
      async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
      async run() { raw.prepare(sql).run(...params); return { success: true }; },
    };
    return s;
  }
  return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}

// prod 實際 triplet template 的 slots（2026-07-19 kbdb_list_templates 核實）——注意：沒有 library。
const PROD_TRIPLET_SLOTS = [
  'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
  'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
  'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
];

async function seedTripletTemplate(db: D1Database): Promise<void> {
  await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
}

async function seedTriplet(
  db: D1Database,
  v: { s: string; p: string; o: string; library?: string; source_uri?: string; status?: string },
  owner = 'leo',
): Promise<string> {
  const values: Record<string, string> = { subject: v.s, predicate: v.p, object: v.o };
  if (v.library) values.library = v.library;
  if (v.source_uri) values.source_uri = v.source_uri;
  if (v.status) values.status = v.status;
  const rec = await createRecord(db, { template: 'triplet', values, owner_id: owner });
  return rec.record_id;
}

function makeApp(db: D1Database) {
  const app = new Hono<{ Bindings: Bindings }>();
  app.route('/map', mapRoutes);
  const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
  return { app, env };
}

describe('M1 — library_map template＋triplet library slot（真 SQLite）', () => {
  it('migration 0003 seed：library_map template 落在 templates 表、slots 齊全（D6 零建表）', async () => {
    const db = makeSqliteD1();
    const tpl = await getTemplate(db, 'library_map');
    expect(tpl).not.toBeNull();
    expect(JSON.parse(tpl!.slots_json)).toEqual(LIBRARY_MAP_SLOTS);
  });

  it('ensureTripletLibrarySlot：prod 形狀（無 library slot）→ 補上；再跑冪等 false', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(true);
    const tpl = await getTemplate(db, 'triplet');
    expect(JSON.parse(tpl!.slots_json)).toContain('library');
    expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(false); // 只增不減、冪等
  });

  it('recompute 對缺 triplet template 的 DB → 誠實丟錯（不假裝算出空地圖）', async () => {
    const db = makeSqliteD1();
    await expect(recomputeLibraryMap(db, { library: 'kb' })).rejects.toThrow('triplet template not found');
  });
});

describe('M2 — recompute 聚合（真 SQLite 實跑 SQL）', () => {
  async function seedKbLibrary(db: D1Database) {
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    // kb 庫：A 出現 3 次（degree 3）、B 2 次、C 1 次；predicate 連結至×2、屬於×1
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
    await seedTriplet(db, { s: 'B', p: '屬於', o: 'A', library: 'kb' });
  }

  it('top_entities degree 排序＋relation_profile＋triplet_count＋content 可嵌人話', async () => {
    const db = makeSqliteD1();
    await seedKbLibrary(db);
    const r = await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫卡片', commit_hash: 'abc123' });
    expect(r.map.triplet_count).toBe(3);
    expect(r.map.top_entities).toEqual([
      { name: 'A', degree: 3 },
      { name: 'B', degree: 2 },
      { name: 'C', degree: 1 },
    ]);
    expect(r.map.relation_profile).toEqual([
      { predicate: '連結至', count: 2 },
      { predicate: '屬於', count: 1 },
    ]);
    // design §5：content = `{library}：{narrative}。核心：{top 前幾個}`（M6 semantic 路由直接嵌）
    expect(r.map.content).toBe('kb：知識庫卡片。核心：A、B、C');
    expect(r.map.commit_hash).toBe('abc123');
    expect(r.superseded).toEqual([]);
    expect(r.triplet_library_slot_added).toBe(false); // seed 已補過 slot → recompute 冪等回 false
  });

  it('superseded triplet 不入地圖（COALESCE status active-only）', async () => {
    const db = makeSqliteD1();
    await seedKbLibrary(db);
    await seedTriplet(db, { s: 'X', p: '連結至', o: 'Y', library: 'kb', status: 'superseded' });
    const r = await recomputeLibraryMap(db, { library: 'kb' });
    expect(r.map.triplet_count).toBe(3);
    expect(r.map.top_entities.map((t) => t.name)).not.toContain('X');
  });

  it('source_prefix fallback：舊 triplet（無 library 值）靠 source_uri 前綴歸庫；不帶 fallback 則不計', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    // 模擬 prod 現況：triplet 只有 source_uri（gitea:Leo/kb@…），沒有 library slot 值
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/00-INDEX.md#seg02' });
    await seedTriplet(db, { s: 'B', p: '連結至', o: 'C', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/x.md' });
    await seedTriplet(db, { s: 'D', p: '連結至', o: 'E', source_uri: 'gitea:Leo/notes@cards/y.md' }); // 他庫
    const strict = await recomputeLibraryMap(db, { library: 'kb' });
    expect(strict.map.triplet_count).toBe(0); // library slot 全空 → 嚴格模式誠實回 0
    const fb = await recomputeLibraryMap(db, { library: 'kb', source_prefix: 'gitea:Leo/kb@' });
    expect(fb.map.triplet_count).toBe(2);
    expect(fb.map.top_entities.map((t) => t.name)).toEqual(['B', 'A', 'C']);
  });

  it('bridges：本庫 entity 出現在其他庫（library slot 標記側）→ 跨庫 join 列出', async () => {
    const db = makeSqliteD1();
    await seedKbLibrary(db);
    await seedTriplet(db, { s: 'A', p: '參與', o: 'Z', library: 'notes' }); // A 橫跨 kb/notes
    const r = await recomputeLibraryMap(db, { library: 'kb' });
    expect(r.map.bridges).toEqual([{ entity: 'A', libraries: ['notes'] }]);
  });

  it('owner 隔離：帶 owner_id 只聚合該 owner 的 triplet（照 base 既有 owner 慣例）', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1');
    await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2');
    const r = await recomputeLibraryMap(db, { library: 'kb', owner_id: 'tenant1' });
    expect(r.map.triplet_count).toBe(1);
    expect(r.map.top_entities.map((t) => t.name).sort()).toEqual(['A', 'B']);
  });

  it('supersede 順序安全：重算兩次 → 舊 map 標 superseded、讀端只見最新 active', async () => {
    const db = makeSqliteD1();
    await seedKbLibrary(db);
    const first = await recomputeLibraryMap(db, { library: 'kb', narrative: '第一版' });
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' });
    const second = await recomputeLibraryMap(db, { library: 'kb', narrative: '第二版' });
    expect(second.superseded).toEqual([first.map.record_id]); // 舊 active 被點名
    const oldRec = await getRecord(db, first.map.record_id);
    expect(oldRec!.values.status).toBe('superseded'); // 沿用既有 status slot 語意（R6）
    const detail = await getLibraryMapDetail(db, 'kb');
    expect(detail!.record_id).toBe(second.map.record_id);
    expect(detail!.narrative).toBe('第二版');
    expect(detail!.triplet_count).toBe(4);
    // 全館視圖同樣只剩一行 kb（superseded 不重複出現）
    const all = await listLibraryMaps(db);
    expect(all.filter((r) => r.library === 'kb')).toHaveLength(1);
  });
});

describe('M2 — route 行為（GET /map、GET /map/:library、POST /map/recompute）', () => {
  it('POST /map/recompute 缺 library → 400；query 傳 library＋body 傳 narrative 可重算', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    const { app, env } = makeApp(db);
    const bad = await app.request('/map/recompute', { method: 'POST' }, env);
    expect(bad.status).toBe(400);
    const ok = await app.request('/map/recompute?library=kb', {
      method: 'POST',
      body: JSON.stringify({ narrative: '知識庫' }),
      headers: { 'content-type': 'application/json' },
    }, env);
    expect(ok.status).toBe(200);
    const body = (await ok.json()) as { success: boolean; map: { library: string; content: string } };
    expect(body.success).toBe(true);
    expect(body.map.library).toBe('kb');
    expect(body.map.content).toContain('kb：知識庫');
  });

  it('GET /map：從未 recompute（template 不存在）→ 誠實空清單；有資料 → 每庫一行、top 3 名字', async () => {
    const empty = makeSqliteD1();
    // 空 DB 連 library_map template 都拿掉，模擬「migration 未跑、也從未 recompute」的自架環境
    await (empty as unknown as { prepare(sql: string): { run(): Promise<unknown> } })
      .prepare("DELETE FROM templates WHERE name = 'library_map'").run();
    const e = makeApp(empty);
    const r0 = await e.app.request('/map', {}, e.env);
    expect(r0.status).toBe(200);
    expect(await r0.json()).toEqual({ success: true, libraries: [], count: 0 });

    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    // kb 庫塞 4 個 entities（驗 top_entities 全館視圖只留 3）
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' });
    await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
    await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫' });
    await recomputeLibraryMap(db, { library: 'notes', narrative: '隨手筆記' });
    const { app, env } = makeApp(db);
    const res = await app.request('/map', {}, env);
    const body = (await res.json()) as { libraries: { library: string; narrative: string; top_entities: string[]; triplet_count: number }[]; count: number };
    expect(body.count).toBe(2);
    const kb = body.libraries.find((l) => l.library === 'kb')!;
    expect(kb.narrative).toBe('知識庫');
    expect(kb.triplet_count).toBe(3);
    expect(kb.top_entities).toEqual(['A', 'B', 'C']); // 每庫一行只留 top 3（R3 數百 token 內）
  });

  it('GET /map/:library：完整 slots＋content；未知庫 404', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
    await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫', commit_hash: 'deadbeef' });
    const { app, env } = makeApp(db);
    const res = await app.request('/map/kb', {}, env);
    expect(res.status).toBe(200);
    const body = (await res.json()) as { map: Record<string, unknown> };
    expect(body.map.library).toBe('kb');
    expect(body.map.commit_hash).toBe('deadbeef');
    expect(body.map.status).toBe('active');
    expect(Array.isArray(body.map.relation_profile)).toBe(true);
    expect(Array.isArray(body.map.bridges)).toBe(true);
    expect(body.map.content).toBe('kb：知識庫。核心：A、B');
    const miss = await app.request('/map/nope', {}, env);
    expect(miss.status).toBe(404);
  });
});

// 2026-08-08：M3 收尾——真因是「等外部呼叫 /map/recompute」這條線三週沒人接（總管實測 grep
// 全 repo 查無呼叫點），沒手動 backfill 過的租戶恆空。修法：讀端自己核對即時三元組數，落差
// 就地補算，不再依賴任何外部呼叫者。以下驗證這條「即時新鮮度」機制本身。
describe('M3 收尾 — 即時新鮮度（ensureFreshLibraryMaps，讀端自動核對重算，不靠外部呼叫 recompute）', () => {
  it('從未手動呼過 recompute：GET /map 第一次讀就自動補齊（全租戶自動 backfill）', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
    await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
    // 注意：這裡沒有呼叫 recomputeLibraryMap，直接打 GET /map。
    const { app, env } = makeApp(db);
    const res = await app.request('/map', {}, env);
    const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[]; count: number };
    expect(body.count).toBe(2);
    const kb = body.libraries.find((l) => l.library === 'kb')!;
    expect(kb.triplet_count).toBe(2);
    const notes = body.libraries.find((l) => l.library === 'notes')!;
    expect(notes.triplet_count).toBe(1);
  });

  it('跟得上資料：先讀一次，再塞新三元組，下一次讀（不手動 recompute）數字要更新', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
    const { app, env } = makeApp(db);
    const first = await app.request('/map', {}, env);
    const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
    expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);

    // 模擬 ingest 進了一筆新資料——不呼叫任何 recompute。
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
    const second = await app.request('/map', {}, env);
    const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
    expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
  });

  it('Arcrun#87 迴歸：superseded triplet 存在時，連讀兩次地圖不會再次觸發重算（不再無止盡寫入）', async () => {
    // 重現票上的根因：liveTripletCountsByLibrary 原本不濾 status，recomputeLibraryMap 只算
    // active——只要庫裡混了 superseded triplet，兩邊算出來的數字永遠對不上，
    // ensureFreshLibraryMaps 就永遠判定 stale，每次讀地圖都重算、每次都新建一筆 record。
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰

    const { app, env } = makeApp(db);

    // 第一次讀：資料是新的（從沒 recompute 過），觸發一次重算是正常的。
    const first = await app.request('/map', {}, env);
    const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
    expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆

    const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;

    // 第二次讀：中間沒有任何寫入動作。修好之前，這裡會再次判定 stale 並多新建一筆 record。
    const second = await app.request('/map', {}, env);
    const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
    expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);

    const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
    expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record

    // 第三次也一樣，多讀幾次確認不是巧合。
    await app.request('/map', {}, env);
    const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
    expect(countAfterThird).toBe(countAfterFirst);
  });

  it('narrative 不會被自動重算靜默洗掉：先人工帶 narrative，之後的自動重算要保留它', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
    await recomputeLibraryMap(db, { library: 'kb', narrative: '人工填過的摘要' });
    // 塞新三元組觸發下一次讀時的自動重算（不帶 narrative）。
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
    await ensureFreshLibraryMaps(db);
    const detail = await getLibraryMapDetail(db, 'kb');
    expect(detail!.triplet_count).toBe(2); // 確認真的有重算（不是沒動過）
    expect(detail!.narrative).toBe('人工填過的摘要'); // 但 narrative 沒被洗掉
  });

  it('GET /map/:library 誠實分辨「查無此庫」(404) vs「已知但目前是空庫」(200＋triplet_count:0)', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    // 'hr' 庫：entries 蓋過章（t52 慣例）但目前沒有任何三元組——已知但空。
    await createEntry(db, {
      content: '人資資料',
      entry_type: 'block',
      owner_id: 'leo',
      metadata_json: JSON.stringify({ library: 'hr' }),
    });
    const { app, env } = makeApp(db);

    const known = await app.request('/map/hr?owner_id=leo', {}, env);
    expect(known.status).toBe(200); // 已知庫，即使是空的也回 200，不是 404
    const knownBody = (await known.json()) as { map: { triplet_count: number } };
    expect(knownBody.map.triplet_count).toBe(0);

    const unknown = await app.request('/map/totally-made-up-name?owner_id=leo', {}, env);
    expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
  });

  it('entry_count 讓「有原始內容但三元組從沒萃取過」與「真的什麼都沒有」分得清（Arcrun#87 三次收尾）', async () => {
    // 情境沿用上一案的 'hr' 庫（entries 蓋過章、triplet_count:0），這正是 leo21c 實測到的真實
    // 現況（kb 以外 7 庫皆此況）：地圖過去只回 triplet_count，AI 讀到 0 就誤判「這庫沒有知識」，
    // 但實際上 kbdb_search 找得到內容——因為 entries 一直都在，只是沒被萃取成三元組。
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await createEntry(db, {
      content: '人資資料 A',
      entry_type: 'block',
      owner_id: 'leo',
      metadata_json: JSON.stringify({ library: 'hr' }),
    });
    await createEntry(db, {
      content: '人資資料 B',
      entry_type: 'block',
      owner_id: 'leo',
      metadata_json: JSON.stringify({ library: 'hr' }),
    });
    const { app, env } = makeApp(db);

    // 單庫詳圖：triplet_count 仍是 0（沒騙這件事），但 entry_count 誠實回 2。
    const detailRes = await app.request('/map/hr?owner_id=leo', {}, env);
    const detailBody = (await detailRes.json()) as { map: { triplet_count: number; entry_count: number } };
    expect(detailBody.map.triplet_count).toBe(0);
    expect(detailBody.map.entry_count).toBe(2);

    // 全館視圖：同一個庫、同一組數字，兩個 consumer（GET /map、GET /map/:library）不能對不上。
    const listRes = await app.request('/map?owner_id=leo', {}, env);
    const listBody = (await listRes.json()) as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
    const hr = listBody.libraries.find((l) => l.library === 'hr');
    expect(hr).toBeDefined();
    expect(hr!.triplet_count).toBe(0);
    expect(hr!.entry_count).toBe(2);
  });

  it('entry_count 的計數口徑排除 value 型 entries 與地圖自己的歷史摘要 block（不自我膨脹）', async () => {
    // record-crud 建 triplet record 時，每個 slot 值都會落一筆 entry_type='value' 的 entry
    // （儲存實作細節，不是「一筆知識」）；地圖 recompute 也會建 entry_type='block' 且
    // metadata.kind='library_map' 的摘要 entry——兩者都不該被算進 entry_count，否則地圖會把
    // 自己的內部管線雜訊當成「使用者知識」回報，數字沒有意義。
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    // 建一條 kb 三元組 → 連帶產生數筆 entry_type='value' 的 entries（不該被算進 entry_count）。
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
    // 觸發一次 recompute → 產生一筆 entry_type='block'/metadata.kind='library_map' 的摘要 entry
    // （同樣不該被算進 entry_count）。
    await recomputeLibraryMap(db, { library: 'kb' });

    const detail = await getLibraryMapDetail(db, 'kb');
    // 這個情境下 kb 沒有任何「真的 ingest 進來的原始內容」entry（只有 value 碎片與地圖自己的摘要），
    // entry_count 應誠實回 0——不能因為底層 entries 表其實有好幾筆就報出一個誤導的非零數字。
    expect(detail!.entry_count).toBe(0);
    expect(detail!.triplet_count).toBe(1); // 對照：triplet_count 不受這個排除規則影響，維持原樣。
  });

  it('owner 隔離：即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
    const db = makeSqliteD1();
    await seedTripletTemplate(db);
    await ensureTripletLibrarySlot(db, 'triplet');
    await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1');
    await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2');
    const { app, env } = makeApp(db);
    const res = await app.request('/map?owner_id=tenant1', {}, env);
    const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[] };
    expect(body.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
  });

  it('沒有 triplet template（這顆 KBDB 從沒建過任何三元組）→ 不報錯，誠實回空清單', async () => {
    // 新鮮 DB：只跑過 migrations（library_map template 有 seed，但沒人叫過 seedTripletTemplate）。
    const fresh = makeSqliteD1();
    await expect(ensureFreshLibraryMaps(fresh)).resolves.toBeUndefined();
    const { app, env } = makeApp(fresh);
    const res = await app.request('/map', {}, env);
    expect(await res.json()).toEqual({ success: true, libraries: [], count: 0 });
  });
});
