Files
Clipboard/test/search-plan.test.js
2026-09-12 13:59:12 +08:00

255 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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`);