This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

254
test/search-plan.test.js Normal file
View File

@@ -0,0 +1,254 @@
'use strict';
/**
* 搜索策略 + trigram 分词器。
*
* 这里守的是一个很容易被"优化"掉的正确性问题:
* FTS5 默认的 unicode61 分词器把一整串连续中文当成一个 token
* 而 MATCH 是整 token 匹配,所以搜"公园"在"今天去公园散步"里搜不到。
* 改用 trigram 后子串能匹配了,但 ≤2 字符的词 trigram 一条也匹配不到,
* 必须退回 LIKE —— 而两字中文词(公园/会议/复制)恰恰是最常见的搜索。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const { planSearch, likePatterns, escapeLike, TRIGRAM_MIN } = require('../lib/search-plan.js');
const { migrate } = require('../lib/migrate.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
// ---------- planSearch纯逻辑 ----------
check('空查询 -> 全部列出', () => {
for (const q of ['', ' ', null, undefined]) {
assert.strictEqual(planSearch(q).mode, 'all', JSON.stringify(q));
}
});
check('短查询(<3 字符)退回 LIKE', () => {
for (const q of ['公园', '会议', '复制', 'ab', 'a']) {
const p = planSearch(q);
assert.strictEqual(p.mode, 'like', `"${q}" 应走 LIKE`);
assert.deepStrictEqual(p.patterns, [`%${q}%`]);
}
});
check('长查询走 FTS', () => {
const p = planSearch('天气很好');
assert.strictEqual(p.mode, 'fts');
assert.strictEqual(p.match, '"天气很好"');
});
check('多 token 全部够长时组成 AND 查询', () => {
assert.strictEqual(planSearch('hello world').match, '"hello" "world"');
});
check('多 token 里只要有一个太短就整体退回 LIKE', () => {
// MATCH 里多个短语是 AND 关系2 字符那个匹配不到会让整个查询返回空
const p = planSearch('天气很好 公园');
assert.strictEqual(p.mode, 'like');
// 每个 token 独立子串、AND 组合(不是把整串连空格当子串)
assert.deepStrictEqual(p.patterns, ['%天气很好%', '%公园%']);
});
// ---------- likePatterns / escapeLikeLIKE 元字符 ----------
check('likePatterns空查询返回空数组', () => {
for (const q of ['', ' ', null, undefined]) {
assert.deepStrictEqual(likePatterns(q), [], JSON.stringify(q));
}
});
check('likePatterns多 token 拆成独立模式', () => {
assert.deepStrictEqual(likePatterns(' hello world '), ['%hello%', '%world%']);
});
check('escapeLike% _ \\ 三个元字符都被转义', () => {
assert.strictEqual(escapeLike('100%'), '100\\%');
assert.strictEqual(escapeLike('a_b'), 'a\\_b');
assert.strictEqual(escapeLike('C:\\path'), 'C:\\\\path');
assert.strictEqual(escapeLike('plain'), 'plain');
});
check('LIKE 元字符进了查询不再被当通配符', () => {
// 不转义时搜 "100%" 会变成 "以 100 开头",匹配一切 100xx
assert.deepStrictEqual(likePatterns('100%'), ['%100\\%%']);
assert.deepStrictEqual(likePatterns('a_b c%d'), ['%a\\_b%', '%c\\%d%']);
});
check('FTS5 保留字符被安全转义(不会变成语法错误)', () => {
assert.strictEqual(planSearch('a"bc').match, '"a""bc"');
for (const q of ['foo-bar', 'a(b)c', 'x*yz', '^abc', 'a:b:c', 'NEAR(a b)']) {
const p = planSearch(q);
if (p.mode === 'fts') assert.ok(p.match.startsWith('"'), q);
}
});
check('按码点算长度,代理对不会被误判为够长', () => {
// 两个 emoji = 2 个码点,但 .length 是 4不做码点处理就会错走 FTS
assert.strictEqual(planSearch('😀😀').mode, 'like');
assert.strictEqual(TRIGRAM_MIN, 3);
});
// ---------- 端到端:真的建库、真的搜 ----------
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-sp-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips(
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT, image BLOB,
preview TEXT, top_at REAL, created_at REAL NOT NULL DEFAULT 0)`);
return db;
}
let fts5 = true;
{
const db = makeDb();
try { db.exec(`CREATE VIRTUAL TABLE t USING fts5(x, tokenize='trigram')`); }
catch (_) { fts5 = false; }
db.close();
}
if (!fts5) {
console.log(' SKIP - 这个 SQLite 构建不支持 trigram FTS5');
} else {
const CORPUS = [
'今天天气很好,我们去公园散步',
'会议记录:项目进度延后两周',
'hello world foo bar',
'请把这段文字复制到剪贴板',
'100% off_sale promo',
'off sale prices today',
];
function seeded() {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)');
CORPUS.forEach((t, i) => ins.run('text', t, i));
assert.strictEqual(migrate(db, 1, 3), 3, '应迁移到 v3');
return db;
}
// 模拟 main.js 的 clips:list 分支searchLikeRows多 token AND + ESCAPE
function search(db, query) {
const plan = planSearch(query);
if (plan.mode === 'all') return db.prepare('SELECT id FROM clips').all().length;
if (plan.mode === 'like') {
const where = plan.patterns.map(() => `text LIKE ? ESCAPE '\\'`).join(' AND ');
return db.prepare(`SELECT id FROM clips WHERE ${where}`).all(...plan.patterns).length;
}
return db.prepare(
'SELECT c.id FROM clips_fts f JOIN clips c ON c.id=f.rowid WHERE clips_fts MATCH ?'
).all(plan.match).length;
}
check('v3 迁移后 clips_fts 用的是 trigram 分词器', () => {
const db = seeded();
const sql = db.prepare(
"SELECT sql FROM sqlite_master WHERE name='clips_fts'"
).get().sql;
assert.match(sql, /tokenize\s*=\s*'trigram'/, `实际: ${sql}`);
db.close();
});
check('中文子串搜索能搜到(这正是 unicode61 做不到的)', () => {
const db = seeded();
assert.strictEqual(search(db, '天气很好'), 1, '4 字中文子串');
assert.strictEqual(search(db, '项目进度'), 1);
assert.strictEqual(search(db, '复制到'), 1, '3 字中文子串');
db.close();
});
check('两字中文词经 LIKE 兜底同样能搜到', () => {
const db = seeded();
for (const [q, n] of [['公园', 1], ['会议', 1], ['复制', 1], ['天气', 1]]) {
assert.strictEqual(search(db, q), n, `"${q}"`);
}
db.close();
});
check('英文搜索照常工作', () => {
const db = seeded();
assert.strictEqual(search(db, 'hello'), 1);
assert.strictEqual(search(db, 'world'), 1);
db.close();
});
check('多 token 搜索是 AND都在才命中', () => {
const db = seeded();
assert.strictEqual(search(db, 'hello world'), 1, '两词都在第 3 条');
assert.strictEqual(search(db, 'hello 公园'), 0, '跨条目不应命中');
db.close();
});
check('搜 LIKE 元字符不会变通配符(端到端)', () => {
const db = seeded();
db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)').run('text', '折扣 100% off', 99);
db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)').run('text', '打折活动 10086', 100);
// '折' 是短 token强制走 LIKE 分支;'100%' 若不转义,% 是通配符,
// '打折活动 10086'(含子串 100也会命中 → 不转义得 2转义后应得 1。
assert.strictEqual(search(db, '折 100%'), 1, '应只命中字面量含 100% 的条目');
db.close();
});
check('搜不到的东西返回 0不是全部', () => {
const db = seeded();
assert.strictEqual(search(db, '不存在的内容'), 0);
db.close();
});
check('每种查询形态的结果都与 LIKE 基准一致', () => {
const db = seeded();
const like = (q) => db.prepare('SELECT id FROM clips WHERE text LIKE ?').all(`%${q}%`).length;
for (const q of ['天气很好', '项目进度', '复制到', 'hello', 'world', '剪贴板', '散步', '公园']) {
assert.strictEqual(search(db, q), like(q), `"${q}" 与 LIKE 基准不一致`);
}
db.close();
});
check('保留字符不会让搜索抛异常', () => {
const db = seeded();
for (const q of ['foo-bar', 'a"b"c', 'x*y', '(paren)', 'a:b', 'NEAR(x y)', '^^^']) {
assert.doesNotThrow(() => search(db, q), `"${q}" 抛异常了`);
}
db.close();
});
check('v2 库能就地升到 v3不丢数据', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)');
CORPUS.forEach((t, i) => ins.run('text', t, i));
assert.strictEqual(migrate(db, 1, 2), 2); // 先停在 v2unicode61
assert.strictEqual(search(db, '天气很好'), 0, 'v2 下中文子串确实搜不到');
assert.strictEqual(migrate(db, 1, 3), 3); // 再升 v3
assert.strictEqual(search(db, '天气很好'), 1, 'v3 修好了');
assert.strictEqual(db.prepare('SELECT COUNT(*) n FROM clips').get().n, CORPUS.length);
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('升到 v3 后触发器仍然同步新增/删除', () => {
const db = seeded();
db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)').run('text', '新增的一条测试内容', 99);
assert.strictEqual(search(db, '新增的一条'), 1, '新插入的应可搜到');
db.prepare("DELETE FROM clips WHERE text='新增的一条测试内容'").run();
assert.strictEqual(search(db, '新增的一条'), 0, '删除后应搜不到');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('v3 迁移可重复执行(幂等)', () => {
const db = seeded();
for (let i = 0; i < 3; i++) migrate(db, 1, 3);
assert.strictEqual(search(db, '天气很好'), 1, '不应出现重复命中');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
}
console.log(`\n${passed} checks passed`);