// 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 { 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(''); expect(hit.snippet).not.toContain(ZWSP); }); });