ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
5.1 KiB
TypeScript
125 lines
5.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import { embedSelfTest } from '../src/embed';
|
||
import type { Bindings, Entry } from '../src/types';
|
||
|
||
// ── Minimal in-memory fakes ───────────────────────────────────────────────
|
||
// embedSelfTest issues exactly one DB statement:
|
||
// SELECT * FROM entries WHERE is_embedded = 1 AND content <> '' [AND owner_id = ?]
|
||
// ORDER BY updated_at DESC LIMIT 1
|
||
// The fake's `first()` filters the in-memory store accordingly and returns the
|
||
// last match (proxy for "ORDER BY updated_at DESC LIMIT 1" given store insertion order).
|
||
function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1): Entry {
|
||
return {
|
||
id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null,
|
||
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
|
||
confidence: null, metadata_json: JSON.stringify({ embed: true }), src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
|
||
};
|
||
}
|
||
|
||
function makeFakeDB(store: Entry[]) {
|
||
const prepare = (_sql: string) => {
|
||
let bound: unknown[] = [];
|
||
const stmt = {
|
||
bind(...args: unknown[]) { bound = args; return stmt; },
|
||
async first<T>() {
|
||
const ownerId = bound.length > 0 ? String(bound[0]) : undefined;
|
||
const rows = store.filter(
|
||
(e) => e.is_embedded === 1 && (e.content ?? '').trim() !== '' && (!ownerId || e.owner_id === ownerId),
|
||
);
|
||
return (rows.length > 0 ? rows[rows.length - 1] : null) as unknown as T;
|
||
},
|
||
async all<T>() { return { results: [] as T[] }; },
|
||
async run() { return { success: true }; },
|
||
};
|
||
return stmt;
|
||
};
|
||
return { prepare } as unknown as D1Database;
|
||
}
|
||
|
||
function makeEnv(
|
||
store: Entry[],
|
||
opts: { withBindings?: boolean; matches?: { id: string; score: number }[] } = {},
|
||
): Bindings {
|
||
const withBindings = opts.withBindings ?? true;
|
||
return {
|
||
DB: makeFakeDB(store),
|
||
ENVIRONMENT: 'test',
|
||
...(withBindings
|
||
? {
|
||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||
VECTORIZE: { async query() { return { matches: opts.matches ?? [] }; } },
|
||
}
|
||
: {}),
|
||
} as unknown as Bindings;
|
||
}
|
||
|
||
describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的可用)', () => {
|
||
it('module off → enabled:false, tested:false, passed:null(誠實不假綠)', async () => {
|
||
const env = makeEnv([mkEntry('e1', 'hello')], { withBindings: false });
|
||
const r = await embedSelfTest(env);
|
||
expect(r.enabled).toBe(false);
|
||
expect(r.tested).toBe(false);
|
||
expect(r.passed).toBeNull();
|
||
expect(typeof r.note).toBe('string');
|
||
});
|
||
|
||
it('沒有任何已嵌入卡片 → tested:false, passed:null(非失敗,只是還沒東西可測)', async () => {
|
||
const env = makeEnv([]);
|
||
const r = await embedSelfTest(env);
|
||
expect(r.enabled).toBe(true);
|
||
expect(r.tested).toBe(false);
|
||
expect(r.passed).toBeNull();
|
||
});
|
||
|
||
it('自我查詢能搜到自己 → passed:true', async () => {
|
||
const store = [mkEntry('e1', 'doorbell workflow content')];
|
||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||
const r = await embedSelfTest(env);
|
||
expect(r.enabled).toBe(true);
|
||
expect(r.tested).toBe(true);
|
||
expect(r.passed).toBe(true);
|
||
});
|
||
|
||
it('自我查詢搜不到自己 → passed:false(Arcrun#11 那種「嵌了但查不到」故障模式)', async () => {
|
||
const store = [mkEntry('e1', 'doorbell workflow content')];
|
||
const env = makeEnv(store, { matches: [{ id: 'some-other-id', score: 0.5 }] });
|
||
const r = await embedSelfTest(env);
|
||
expect(r.enabled).toBe(true);
|
||
expect(r.tested).toBe(true);
|
||
expect(r.passed).toBe(false);
|
||
});
|
||
|
||
it('向量化本身失敗(AI 額度用完)→ tested:false+note 說明故障,不 throw 也不假 passed', async () => {
|
||
const store = [mkEntry('e1', '取樣內容', 'o1')];
|
||
const env = {
|
||
DB: makeFakeDB(store),
|
||
ENVIRONMENT: 'test',
|
||
AI: { async run() { throw new Error('3040: daily limit'); } },
|
||
VECTORIZE: { async query() { return { matches: [] }; } },
|
||
} as unknown as Bindings;
|
||
const r = await embedSelfTest(env, { owner_id: 'o1' });
|
||
expect(r.enabled).toBe(true);
|
||
expect(r.tested).toBe(false);
|
||
expect(r.passed).toBeNull();
|
||
expect(r.note).toContain('沒跑成');
|
||
});
|
||
|
||
it('依 owner_id 隔離:別的租戶的已嵌入卡片不會被拿來測', async () => {
|
||
const store = [mkEntry('e1', 'content', 'other-tenant')];
|
||
const env = makeEnv(store, { matches: [] });
|
||
const r = await embedSelfTest(env, { owner_id: 'leo' });
|
||
expect(r.enabled).toBe(true);
|
||
expect(r.tested).toBe(false);
|
||
expect(r.passed).toBeNull();
|
||
});
|
||
|
||
it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => {
|
||
const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
|
||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||
const r = await embedSelfTest(env);
|
||
const json = JSON.stringify(r);
|
||
expect(json).not.toContain('e1');
|
||
expect(json).not.toContain('secret card body');
|
||
});
|
||
});
|