| |
| |
| |
| import test from 'node:test'; |
| import assert from 'node:assert/strict'; |
| import { readFileSync } from 'node:fs'; |
| import { dirname, join } from 'node:path'; |
| import { fileURLToPath } from 'node:url'; |
| import { runInThisContext } from 'node:vm'; |
| import { webcrypto } from 'node:crypto'; |
|
|
| if (!globalThis.crypto) { |
| Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }); |
| } |
|
|
| const dir = dirname(fileURLToPath(import.meta.url)); |
| const src = readFileSync(join(dir, '../semantic/analyzeCache.js'), 'utf8'); |
| runInThisContext(src, { filename: 'analyzeCache.js' }); |
| const cache = globalThis.IL_analyzeCache; |
|
|
| test.beforeEach(() => { |
| cache.clear(); |
| delete globalThis.chrome; |
| }); |
|
|
| function mockLocal(initial = {}) { |
| const data = { ...initial }; |
| globalThis.chrome = { |
| storage: { |
| local: { |
| async get(key) { |
| if (key == null) return { ...data }; |
| if (Array.isArray(key)) { |
| const out = {}; |
| for (const k of key) { |
| if (k in data) out[k] = data[k]; |
| } |
| return out; |
| } |
| return { [key]: data[key] }; |
| }, |
| async set(obj) { |
| Object.assign(data, obj); |
| }, |
| async remove(key) { |
| for (const k of Array.isArray(key) ? key : [key]) delete data[k]; |
| }, |
| }, |
| }, |
| }; |
| return data; |
| } |
|
|
| function rowsOf(texts, degrees) { |
| return async (query, sent, onRow) => { |
| sent.forEach((t, i) => onRow(i + 1, degrees[texts.indexOf(t)])); |
| }; |
| } |
|
|
| test('全未命中:一次 send 全部 texts,再读走缓存', async () => { |
| const texts = ['a', 'b', 'c']; |
| const sent = []; |
| const send = async (query, ts, onRow) => { |
| sent.push(ts.slice()); |
| ts.forEach((_, i) => onRow(i + 1, [0.1, 0.2, 0.3][i])); |
| }; |
| const rows = []; |
| await cache.relevance('q', texts, (n, d) => rows.push([n, d]), undefined, send); |
| assert.deepEqual(sent, [['a', 'b', 'c']]); |
| assert.deepEqual(rows, [ |
| [1, 0.1], |
| [2, 0.2], |
| [3, 0.3], |
| ]); |
|
|
| sent.length = 0; |
| rows.length = 0; |
| await cache.relevance('q', texts, (n, d) => rows.push([n, d]), undefined, send); |
| assert.deepEqual(sent, []); |
| assert.deepEqual(rows, [ |
| [1, 0.1], |
| [2, 0.2], |
| [3, 0.3], |
| ]); |
| }); |
|
|
| test('全命中:不调用 send', async () => { |
| const texts = ['x', 'y']; |
| await cache.relevance('q', texts, () => {}, undefined, rowsOf(texts, [0.4, 0.9])); |
| let called = 0; |
| const rows = []; |
| await cache.relevance( |
| 'q', |
| texts, |
| (n, d) => rows.push([n, d]), |
| undefined, |
| async () => { |
| called += 1; |
| } |
| ); |
| assert.equal(called, 0); |
| assert.deepEqual(rows, [ |
| [1, 0.4], |
| [2, 0.9], |
| ]); |
| }); |
|
|
| test('部分在缓存:回放前缀,只 send 后缀', async () => { |
| await cache.relevance('q', ['a'], () => {}, undefined, async (_q, _ts, onRow) => { |
| onRow(1, 0.5); |
| }); |
| const sent = []; |
| const rows = []; |
| await cache.relevance( |
| 'q', |
| ['a', 'b', 'c'], |
| (n, d) => rows.push([n, d]), |
| undefined, |
| async (_q, ts, onRow) => { |
| sent.push(ts.slice()); |
| ts.forEach((_, i) => onRow(i + 1, 0.2)); |
| } |
| ); |
| assert.deepEqual(sent, [['b', 'c']]); |
| assert.deepEqual(rows, [ |
| [1, 0.5], |
| [2, 0.2], |
| [3, 0.2], |
| ]); |
| }); |
|
|
| test('relevance row 已到则写入;send 随后失败不影响已到的 degree', async () => { |
| const send = async (_q, ts, onRow) => { |
| onRow(1, 0.7); |
| throw new Error('network'); |
| }; |
| await assert.rejects(() => cache.relevance('q', ['a', 'b'], () => {}, undefined, send)); |
|
|
| const sent = []; |
| const rows = []; |
| await cache.relevance( |
| 'q', |
| ['a', 'b'], |
| (n, d) => rows.push([n, d]), |
| undefined, |
| async (_q, ts, onRow) => { |
| sent.push(ts.slice()); |
| ts.forEach((_, i) => onRow(i + 1, 0.3)); |
| } |
| ); |
| assert.deepEqual(sent, [['b']]); |
| assert.deepEqual(rows, [ |
| [1, 0.7], |
| [2, 0.3], |
| ]); |
| }); |
|
|
| test('cachedWindowLength:满窗都在则不截', () => { |
| const all = [0, 0.2, 0.05]; |
| assert.equal(cache.cachedWindowLength(all, 0.1), 3); |
| }); |
|
|
| test('cachedWindowLength:前缀有匹配、后面有洞 → 收到连续缓存末', () => { |
| assert.equal(cache.cachedWindowLength([0, 0.2, 0.05, undefined, 0.9], 0.1), 3); |
| }); |
|
|
| test('cachedWindowLength:前缀无匹配且有洞 → 从洞起再取 maxSend', () => { |
| assert.equal(cache.cachedWindowLength([0, 0.05, undefined, 0.9], 0.1), 4); |
| const degrees = [0, 0.05, undefined, ...Array(40).fill(undefined)]; |
| assert.equal(cache.cachedWindowLength(degrees, 0.1, 32), 34); |
| }); |
|
|
| test('cachedWindowLength:第一块就未缓存 → 整窗', () => { |
| assert.equal(cache.cachedWindowLength([undefined, 0.9], 0.1), 2); |
| }); |
|
|
| test('windowPlan:前缀有匹配则只取连续缓存,不把后面的洞算进窗', async () => { |
| await cache.relevance('q', ['a', 'b', 'c'], () => {}, undefined, async (_q, ts, onRow) => { |
| ts.forEach((t, i) => onRow(i + 1, t === 'b' ? 0.2 : 0.05)); |
| }); |
| const plan = await cache.windowPlan('q', ['a', 'b', 'c', 'd'], 0.1); |
| assert.equal(plan.n, 3); |
| assert.deepEqual(plan.degrees, [0.05, 0.2, 0.05]); |
| }); |
|
|
| test('windowPlan:前缀无匹配有洞 → 前缀加 maxSend,不把前缀算进配额', async () => { |
| const texts = Array.from({ length: 50 }, (_, i) => `t${i}`); |
| await cache.relevance('q', texts.slice(0, 10), () => {}, undefined, async (_q, ts, onRow) => { |
| ts.forEach((_, i) => onRow(i + 1, 0.05)); |
| }); |
| const plan = await cache.windowPlan('q', texts, 0.1, 32); |
| assert.equal(plan.n, 42); |
| assert.equal(plan.degrees.length, 32); |
| assert.equal(plan.degrees[9], 0.05); |
| assert.equal(plan.degrees[10], undefined); |
| }); |
|
|
| test('windowPlan:遇洞即停,不扫完全部剩余', async () => { |
| mockLocal(); |
| const api = globalThis.chrome.storage.local; |
| const orig = api.get; |
| let maxKeys = 0; |
| api.get = async (key) => { |
| if (Array.isArray(key)) maxKeys = Math.max(maxKeys, key.length); |
| return orig.call(api, key); |
| }; |
| const texts = Array.from({ length: 80 }, (_, i) => `t${i}`); |
| const plan = await cache.windowPlan('q', texts, 0.1, 32); |
| assert.equal(plan.n, 32); |
| assert.ok(maxKeys > 0 && maxKeys <= 32); |
| }); |
|
|
| function planNeedsSend(plan) { |
| if (plan.n > plan.degrees.length) return true; |
| for (let i = 0; i < plan.n; i++) { |
| if (!Number.isFinite(plan.degrees[i])) return true; |
| } |
| return false; |
| } |
|
|
| test('定窗是否打网:全缓存 / 有匹配前缀停在洞前 → 否;从洞起 → 是', async () => { |
| await cache.relevance('q', ['a', 'b', 'c'], () => {}, undefined, async (_q, ts, onRow) => { |
| ts.forEach((t, i) => onRow(i + 1, t === 'b' ? 0.2 : 0.05)); |
| }); |
| assert.equal(planNeedsSend(await cache.windowPlan('q', ['a', 'b', 'c'], 0.1)), false); |
| assert.equal(planNeedsSend(await cache.windowPlan('q', ['a', 'b', 'c', 'd'], 0.1)), false); |
| assert.equal(planNeedsSend(await cache.windowPlan('q', ['d', 'e'], 0.1, 32)), true); |
| }); |
|
|
| test('一次 send 的多行只改一次占用表', async () => { |
| mockLocal(); |
| const api = globalThis.chrome.storage.local; |
| const orig = api.set; |
| let orderWrites = 0; |
| api.set = async (obj) => { |
| if ('il_ac/order' in obj) orderWrites += 1; |
| return orig.call(api, obj); |
| }; |
| await cache.relevance('q', ['a', 'b', 'c'], () => {}, undefined, async (_q, ts, onRow) => { |
| ts.forEach((_, i) => onRow(i + 1, 0.2)); |
| }); |
| assert.equal(orderWrites, 1); |
| }); |
|
|
| test('keywords 成功(含空 runs)才写;再读不打网', async () => { |
| let calls = 0; |
| const sendEmpty = async () => { |
| calls += 1; |
| }; |
| await cache.keywords('q', 't0', () => {}, undefined, sendEmpty); |
| await cache.keywords('q', 't0', () => {}, undefined, sendEmpty); |
| assert.equal(calls, 1); |
|
|
| const runs = [ |
| { offset: [0, 2], raw: 'ab', score: 0.9 }, |
| { offset: [4, 6], raw: 'cd', score: 0.4 }, |
| ]; |
| await cache.keywords('q', 't1', () => {}, undefined, async (_q, _t, onRun) => { |
| calls += 1; |
| for (const r of runs) onRun(r); |
| }); |
| const replayed = []; |
| await cache.keywords('q', 't1', (r) => replayed.push(r), undefined, async () => { |
| calls += 1; |
| }); |
| assert.equal(calls, 2); |
| assert.deepEqual(replayed, [ |
| { offset: [0, 2], score: 0.9 }, |
| { offset: [4, 6], score: 0.4 }, |
| ]); |
| }); |
|
|
| test('keywords abort / 失败不写', async () => { |
| const boom = async (_q, _t, onRun) => { |
| onRun({ offset: [0, 1], score: 1 }); |
| throw new Error('aborted'); |
| }; |
| await assert.rejects(() => cache.keywords('q', 'tx', () => {}, undefined, boom)); |
|
|
| let calls = 0; |
| const seen = []; |
| await cache.keywords( |
| 'q', |
| 'tx', |
| (r) => seen.push(r), |
| undefined, |
| async (_q, _t, onRun) => { |
| calls += 1; |
| onRun({ offset: [2, 3], score: 0.5 }); |
| } |
| ); |
| assert.equal(calls, 1); |
| assert.deepEqual(seen, [{ offset: [2, 3], score: 0.5 }]); |
| }); |
|
|
| test('再次执行脚本不丢已有缓存', async () => { |
| await cache.relevance('q', ['keep'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.6); |
| }); |
| runInThisContext(src, { filename: 'analyzeCache.js' }); |
| assert.equal(globalThis.IL_analyzeCache, cache); |
| let called = 0; |
| await globalThis.IL_analyzeCache.relevance( |
| 'q', |
| ['keep'], |
| () => {}, |
| undefined, |
| async () => { |
| called += 1; |
| } |
| ); |
| assert.equal(called, 0); |
| }); |
|
|
| test('storage:内存清空后从 local 命中', async () => { |
| mockLocal(); |
| await cache.relevance('q', ['a'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.4); |
| }); |
| cache.clear(); |
| let called = 0; |
| await cache.relevance('q', ['a'], () => {}, undefined, async () => { |
| called += 1; |
| }); |
| assert.equal(called, 0); |
| }); |
|
|
| test('并发写入都进环,再读不打网', async () => { |
| mockLocal(); |
| await Promise.all( |
| ['a', 'b', 'c'].map((t) => |
| cache.relevance('q', [t], () => {}, undefined, async (_q, _ts, onRow) => { |
| onRow(1, 0.2); |
| }) |
| ) |
| ); |
| let called = 0; |
| await cache.relevance('q', ['a', 'b', 'c'], () => {}, undefined, async () => { |
| called += 1; |
| }); |
| assert.equal(called, 0); |
| }); |
|
|
| test('满员时覆盖环上最旧条', async () => { |
| const data = mockLocal(); |
| const keys = []; |
| for (let i = 0; i < cache.MAX_ENTRIES; i++) { |
| const k = `il_ac/r/${String(i).padStart(64, '0')}`; |
| keys.push(k); |
| data[k] = 0.1; |
| } |
| data['il_ac/order'] = { keys, i: 0 }; |
| await cache.relevance('q', ['new'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.9); |
| }); |
| assert.equal(data[keys[0]], undefined); |
| assert.equal(data[keys[1]], 0.1); |
| assert.equal(data[`il_ac/r/${await cache.key('q', 'new')}`], 0.9); |
| }); |
|
|
| test('storage:按条写入后可直接命中', async () => { |
| const k = await cache.key('q', 'a'); |
| mockLocal({ [`il_ac/r/${k}`]: 0.99 }); |
| let called = 0; |
| await cache.relevance('q', ['a'], () => {}, undefined, async () => { |
| called += 1; |
| }); |
| assert.equal(called, 0); |
| }); |
|
|
| test('文本哈希为 32 位十六进制', async () => { |
| const k = await cache.key('q', 'hello'); |
| const th = k.slice(k.lastIndexOf('/') + 1); |
| assert.equal(th.length, 32); |
| assert.match(th, /^[0-9a-f]{32}$/); |
| }); |
|
|
| test('键含 query:不同 query 不命中', async () => { |
| await cache.relevance('q1', ['same'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.1); |
| }); |
| let called = 0; |
| await cache.relevance('q2', ['same'], () => {}, undefined, async (_q, _t, onRow) => { |
| called += 1; |
| onRow(1, 0.9); |
| }); |
| assert.equal(called, 1); |
| }); |
|
|
| test('syncRemoteModel:每次打开都 fetch', async () => { |
| const data = mockLocal(); |
| let n = 0; |
| const fetchVer = async () => { |
| n += 1; |
| return { relevance: 1, keywords: 1 }; |
| }; |
| await cache.syncRemoteModel(fetchVer); |
| await cache.syncRemoteModel(fetchVer); |
| assert.equal(n, 2); |
| assert.equal(data['il_ac/meta'].relevanceKey, '2:1'); |
| assert.equal(data['il_ac/meta'].keywordsKey, '2:1'); |
| }); |
|
|
| test('syncRemoteModel:只升相关度不清 keywords', async () => { |
| mockLocal(); |
| await cache.syncRemoteModel(async () => ({ relevance: 1, keywords: 1 })); |
| await cache.relevance('q', ['a'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.9); |
| }); |
| await cache.keywords('q', 'a', () => {}, undefined, async (_q, _t, onRun) => { |
| onRun({ offset: [0, 1], score: 1 }); |
| }); |
| cache.clear(); |
| await cache.syncRemoteModel(async () => ({ relevance: 2, keywords: 1 })); |
| let rel = 0; |
| await cache.relevance('q', ['a'], () => {}, undefined, async () => { |
| rel += 1; |
| }); |
| let kw = 0; |
| await cache.keywords('q', 'a', () => {}, undefined, async () => { |
| kw += 1; |
| }); |
| assert.equal(rel, 1); |
| assert.equal(kw, 0); |
| }); |
|
|
| test('syncRemoteModel:只升 keywords 不清相关度', async () => { |
| mockLocal(); |
| await cache.syncRemoteModel(async () => ({ relevance: 1, keywords: 1 })); |
| await cache.relevance('q', ['a'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.9); |
| }); |
| await cache.keywords('q', 'a', () => {}, undefined, async (_q, _t, onRun) => { |
| onRun({ offset: [0, 1], score: 1 }); |
| }); |
| cache.clear(); |
| await cache.syncRemoteModel(async () => ({ relevance: 1, keywords: 2 })); |
| let rel = 0; |
| await cache.relevance('q', ['a'], () => {}, undefined, async () => { |
| rel += 1; |
| }); |
| let kw = 0; |
| await cache.keywords('q', 'a', () => {}, undefined, async () => { |
| kw += 1; |
| }); |
| assert.equal(rel, 0); |
| assert.equal(kw, 1); |
| }); |
|
|
| test('只升 keywords 且环已满:不拧淘汰指针', async () => { |
| const data = mockLocal(); |
| const keys = []; |
| for (let i = 0; i < cache.MAX_ENTRIES; i++) { |
| const k = `il_ac/r/${String(i).padStart(64, '0')}`; |
| keys.push(k); |
| data[k] = 0.1; |
| } |
| data['il_ac/order'] = { keys, i: 7 }; |
| data['il_ac/meta'] = { relevanceKey: '2:1', keywordsKey: '2:1' }; |
| await cache.syncRemoteModel(async () => ({ relevance: 1, keywords: 2 })); |
| assert.equal(data['il_ac/order'].i, 7); |
| assert.equal(data['il_ac/order'].keys.length, cache.MAX_ENTRIES); |
| }); |
|
|
| test('丢一类后剩余按年龄排', async () => { |
| const data = mockLocal(); |
| const keys = []; |
| const kNew = `il_ac/k/${'a'.repeat(64)}`; |
| const kOld = `il_ac/k/${'b'.repeat(64)}`; |
| for (let i = 0; i < cache.MAX_ENTRIES; i++) { |
| const k = |
| i === 0 ? kNew : i === cache.MAX_ENTRIES - 1 ? kOld : `il_ac/r/${String(i).padStart(64, '0')}`; |
| keys.push(k); |
| data[k] = k.startsWith('il_ac/k/') ? [] : 0.1; |
| } |
| data['il_ac/order'] = { keys, i: 1 }; |
| data['il_ac/meta'] = { relevanceKey: '2:1', keywordsKey: '2:1' }; |
| await cache.syncRemoteModel(async () => ({ relevance: 2, keywords: 1 })); |
| assert.deepEqual(data['il_ac/order'].keys, [kOld, kNew]); |
| assert.equal(data['il_ac/order'].i, 0); |
| }); |
|
|
| test('打开时清掉旧整包 key', async () => { |
| const data = mockLocal({ il_analyze_cache: { degrees: {}, runs: {} } }); |
| await cache.syncRemoteModel(async () => ({ relevance: 1, keywords: 1 })); |
| assert.equal(data.il_analyze_cache, undefined); |
| }); |
|
|
| test('usage:条数为数据 key,不含 meta/order', async () => { |
| mockLocal(); |
| await cache.relevance('q', ['a', 'b'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.1); |
| onRow(2, 0.2); |
| }); |
| await cache.keywords('q', 'a', () => {}, undefined, async (_q, _t, onRun) => { |
| onRun({ offset: [0, 1], score: 1 }); |
| }); |
| const u = await cache.usage(); |
| assert.equal(u.entries, 3); |
| assert.ok(u.bytes > 0); |
| }); |
|
|
| test('dropQuery:只丢该 query 的 r/k,其它 query 留下', async () => { |
| mockLocal(); |
| await cache.relevance('q1', ['a', 'b'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.1); |
| onRow(2, 0.2); |
| }); |
| await cache.relevance('q2', ['a'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.3); |
| }); |
| await cache.keywords('q1', 'a', () => {}, undefined, async (_q, _t, onRun) => { |
| onRun({ offset: [0, 1], score: 1 }); |
| }); |
| await cache.dropQuery('q1'); |
| let q1 = 0; |
| await cache.relevance('q1', ['a'], () => {}, undefined, async () => { |
| q1 += 1; |
| }); |
| assert.equal(q1, 1); |
| let q2 = 0; |
| await cache.relevance('q2', ['a'], () => {}, undefined, async () => { |
| q2 += 1; |
| }); |
| assert.equal(q2, 0); |
| let kw = 0; |
| await cache.keywords('q1', 'a', () => {}, undefined, async () => { |
| kw += 1; |
| }); |
| assert.equal(kw, 1); |
| }); |
|
|
| test('dropQuery:query 含斜杠不误伤前缀', async () => { |
| mockLocal(); |
| await cache.relevance('a', ['t'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.1); |
| }); |
| await cache.relevance('a/b', ['t'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.2); |
| }); |
| await cache.dropQuery('a'); |
| let a = 0; |
| await cache.relevance('a', ['t'], () => {}, undefined, async () => { |
| a += 1; |
| }); |
| assert.equal(a, 1); |
| let ab = 0; |
| await cache.relevance('a/b', ['t'], () => {}, undefined, async () => { |
| ab += 1; |
| }); |
| assert.equal(ab, 0); |
| }); |
|
|
| test('dropAll:清掉缓存条,不碰其它 key', async () => { |
| const data = mockLocal({ il_semantic_find_history: ['keep'] }); |
| await cache.relevance('q', ['a'], () => {}, undefined, async (_q, _t, onRow) => { |
| onRow(1, 0.4); |
| }); |
| await cache.dropAll(); |
| const u = await cache.usage(); |
| assert.equal(u.entries, 0); |
| assert.equal(u.bytes, 0); |
| assert.deepEqual(data.il_semantic_find_history, ['keep']); |
| assert.equal(Object.keys(data).some((k) => k.startsWith('il_ac/')), false); |
| let called = 0; |
| await cache.relevance('q', ['a'], () => {}, undefined, async (_q, _t, onRow) => { |
| called += 1; |
| onRow(1, 0.4); |
| }); |
| assert.equal(called, 1); |
| }); |
|
|
| test('syncRemoteModel:fetch 失败不改 key,下次仍问', async () => { |
| mockLocal(); |
| await assert.rejects(() => cache.syncRemoteModel(async () => { |
| throw new Error('net'); |
| })); |
| let n = 0; |
| await cache.syncRemoteModel(async () => { |
| n += 1; |
| return { relevance: 1, keywords: 1 }; |
| }); |
| assert.equal(n, 1); |
| }); |
|
|