This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
// tests/unit/main/cjk-mixed-script-search.test.ts
//
// 守 r208「中英混排查询 0 命中」契约。
//
// 现象(2026-10-20 内存库实测,真 SQLite + 真 FTS5):
// 文档正文:「本文介绍 CNN 图像分类模型 的基本原理。」
// 搜「CNN 模型」(带空格) → 1 条
// 搜「CNN模型」(不带空格) → **0 条**
// 同一篇文档,只是少打一个空格,结果从"搜得到"变成"搜不到"。
//
// 根因:r196 之后,含 CJK 的查询 token 一律被拼成**一个 FTS5 短语**
// (「CNN模型」→「"CNN 模 型"」)。FTS5 短语要求 token **相邻**,而
// 索引侧把每个汉字切成了独立 token ⇒ 文档里必须出现连续的
// 「CNN 模 型」才命中。文档写的是「CNN 图像分类模型」——中间隔着
// 「图像分类」四个 token ⇒ 短语不成立 ⇒ 0 结果。
// 而带空格的写法被切成两个 token(CNN* AND "模 型")⇒ 能命中。
//
// 修法:混排 token 按**文字系统**切 run,每个 run 一个子句,子句之间
// 用空格(= FTS5 隐式 AND)连接 ⇒「CNN模型」与「CNN 模型」产生
// 完全相同的查询串,带不带空格不再影响结果。
//
// 本测锁四件事:
// 1. 混排 token 与「带空格」写法产生**完全相同**的查询串(核心不变量);
// 2. 混排 token 不再被当成一个整体短语;
// 3. 纯中文 / 纯拉丁的既有行为**不变**(不顺手放宽);
// 4. 端到端(真 SQLite + 真 FTS5):不写空格也能搜到、缺一侧不命中
// (是 AND 不是 OR)、片段高亮正常且无 ZWSP 残留。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fileURLToPath } from 'node:url';
import type { NoteResource, ResourceId } from '../../../src/shared/types';
type Db = import('better-sqlite3').Database;
// 主进程 db 句柄由 sqlite.ts 的 getDB 提供 —— 测试里换成内存库。
const { dbRef } = vi.hoisted(() => ({ dbRef: { current: null as Db | null } }));
vi.mock('../../../src/main/db/sqlite', () => ({
getDB: () => {
if (!dbRef.current) throw new Error('test db not initialized');
return dbRef.current;
},
}));
const { ZWSP, splitScriptRuns } = await import('../../../src/main/db/cjk-segment');
const { sanitizeFtsQuery, upsertResource, searchResources } = await import(
'../../../src/main/db/resources-repo'
);
const { applyMigrations } = await import('../../../src/main/db/migrator');
const MIGRATIONS_DIR = fileURLToPath(
new URL('../../../src/main/db/migrations', import.meta.url),
);
async function openDb(): Promise<Db> {
const mod = (await import('better-sqlite3')) as unknown as { default: new (p: string) => Db };
return new mod.default(':memory:');
}
function note(id: string, title: string): NoteResource {
return {
kind: 'note',
id: id as ResourceId,
title,
relPath: `${id}.md`,
tags: [],
size: 1,
mtime: 1,
indexedAt: 1,
};
}
function insertNote(id: string, title: string, body: string): void {
upsertResource(note(id, title), body, `/abs/${id}.md`);
}
describe('按文字系统切 run(r208 纯函数)', () => {
it('连续汉字合成一个 run,汉字与拉丁各成一段', () => {
expect(splitScriptRuns('模型')).toEqual([{ cjk: true, text: '模型' }]);
expect(splitScriptRuns('CNN模型')).toEqual([
{ cjk: false, text: 'CNN' },
{ cjk: true, text: '模型' },
]);
expect(splitScriptRuns('模型CNN')).toEqual([
{ cjk: true, text: '模型' },
{ cjk: false, text: 'CNN' },
]);
expect(splitScriptRuns('Attention机制')).toEqual([
{ cjk: false, text: 'Attention' },
{ cjk: true, text: '机制' },
]);
});
it('纯拉丁 / 空串', () => {
expect(splitScriptRuns('transformer')).toEqual([{ cjk: false, text: 'transformer' }]);
expect(splitScriptRuns('')).toEqual([]);
});
});
describe('查询串构造(r208)', () => {
it('核心不变量:混排 token 与「带空格」写法产生完全相同的查询串', () => {
expect(sanitizeFtsQuery('CNN模型')).toBe(sanitizeFtsQuery('CNN 模型'));
expect(sanitizeFtsQuery('模型CNN')).toBe(sanitizeFtsQuery('模型 CNN'));
expect(sanitizeFtsQuery('Attention机制')).toBe(sanitizeFtsQuery('Attention 机制'));
});
it('混排 token 不再被当成一个整体短语(否则要求跨文字系统相邻)', () => {
expect(sanitizeFtsQuery('CNN模型')).not.toBe('"CNN 模 型"');
expect(sanitizeFtsQuery('CNN模型')).toBe('CNN* "模 型"');
});
it('纯中文仍是短语(r196 行为不变)', () => {
expect(sanitizeFtsQuery('学习')).toBe('"学 习"');
expect(sanitizeFtsQuery('注意力')).toBe('"注 意 力"');
});
it('纯拉丁仍是前缀通配(旧行为不变)', () => {
expect(sanitizeFtsQuery('transformer')).toBe('transformer*');
expect(sanitizeFtsQuery('v1.2')).toBe('"v1.2"');
});
it('token 之间的空格仍是 AND(r196 行为不变)', () => {
expect(sanitizeFtsQuery('学习 transformer')).toBe('"学 习" transformer*');
});
it('空查询返回空串', () => {
expect(sanitizeFtsQuery('')).toBe('');
expect(sanitizeFtsQuery(' ')).toBe('');
});
});
describe('端到端:真 SQLite + 真 FTS5(r208)', () => {
let db: Db;
beforeEach(async () => {
db = await openDb();
applyMigrations(db, MIGRATIONS_DIR);
dbRef.current = db;
});
afterEach(() => {
dbRef.current = null;
db.close();
});
it('不写空格也能搜到(旧实现 0 结果)', () => {
insertNote('note-1', '混合笔记', '本文介绍 CNN 图像分类模型 的基本原理。');
expect(searchResources('CNN模型').map((h) => h.resource.id)).toEqual(['note-1']);
// 带空格的写法一直能命中 —— 两者现在结果一致
expect(searchResources('CNN 模型').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('跨文字系统不相邻也能搜到(旧实现要求相邻)', () => {
insertNote('note-1', '注意力机制', 'Attention 的应用很广,机制 相当复杂。');
expect(searchResources('Attention机制').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('缺一侧不命中 —— 是 AND 不是 OR', () => {
insertNote('note-a', '卷积网络', 'CNN 是卷积神经网络的简称。');
insertNote('note-b', '抽象模型', '模型 是对事物的抽象。');
expect(searchResources('CNN模型')).toEqual([]);
});
it('片段带高亮标记,且不含 ZWSP 残留', () => {
insertNote('note-1', '混合笔记', '本文介绍 CNN 图像分类模型 的基本原理。');
const hit = searchResources('CNN模型')[0]!;
expect(hit.snippet).toContain('<mark>');
expect(hit.snippet).not.toContain(ZWSP);
});
});