'use strict'; const assert = require('node:assert'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const fav = require('../favorites.js'); // check 支持 async fn —— 之前是同步的,传入 async 函数时断言在 await 之后 // 失败会变成 unhandledRejection(summary 还先把这条计进了 passed)。 async function check(name, fn) { try { await fn(); console.log(' ok -', name); passed++; } catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; } } let passed = 0; const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-fav-')); const dbPath = path.join(tmpHome, 'favorites.db'); check('open 创建 db 文件并返回实例', () => { const db = fav.open(dbPath); assert.ok(db, 'db 应该非空'); assert.ok(fs.existsSync(dbPath), 'db 文件应存在'); fav.close(db); }); check('建表:favorites 表存在且列正确', () => { const db = fav.open(dbPath); const cols = db.prepare("PRAGMA table_info(favorites)").all(); const names = cols.map(c => c.name); assert.deepStrictEqual(names, ['id','type','text','image','preview','source_clip_id','created_at','favorited_at']); fav.close(db); }); check('索引 idx_fav_favorited_at 存在', () => { const db = fav.open(dbPath); const idx = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_fav_favorited_at'").get(); assert.ok(idx, '索引应存在'); fav.close(db); }); check('source_clip_id 上有 UNIQUE 约束', () => { const db = fav.open(dbPath); const idx = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='favorites'").get(); assert.ok(/UNIQUE\s*\(\s*source_clip_id\s*\)/i.test(idx.sql), '建表 SQL 应包含 UNIQUE(source_clip_id)'); fav.close(db); }); check('add 插入文本并能取回', () => { const dbPath2 = path.join(tmpHome, 'add-text.db'); const db = fav.open(dbPath2); const now = Date.now() / 1000; const clip = { id: 101, type: 'text', text: 'hello world', preview: 'hello world', createdAt: now }; const row = fav.add(db, clip); assert.ok(row.id > 0, '应返回新 id'); assert.strictEqual(row.type, 'text'); assert.strictEqual(row.text, 'hello world'); assert.strictEqual(row.sourceClipId, 101); assert.strictEqual(row.preview, 'hello world'); fav.close(db); }); check('add 重复 source_clip_id 抛错', () => { const dbPath2 = path.join(tmpHome, 'add-dup.db'); const db = fav.open(dbPath2); const now = Date.now() / 1000; const clip = { id: 102, type: 'text', text: 'first', preview: 'first', createdAt: now }; fav.add(db, clip); let threw = false; try { fav.add(db, { ...clip, text: 'second' }); } catch (e) { threw = /UNIQUE/i.test(e.message); } assert.ok(threw, '重复 source_clip_id 应抛 UNIQUE 错误'); fav.close(db); }); check('add 插入图片并保留 BLOB', () => { const dbPath2 = path.join(tmpHome, 'add-img.db'); const db = fav.open(dbPath2); const now = Date.now() / 1000; const buf = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // 假 PNG 头 const clip = { id: 103, type: 'image', image: buf, preview: '[图片]', createdAt: now }; const row = fav.add(db, clip); assert.strictEqual(row.type, 'image'); // 列表/详情不再内联图片数据,只给一个 hasImage 标记; // 真正的字节走 getImage() 按需取(否则列表要背几百 MB 的 base64) assert.strictEqual(row.hasImage, true); assert.strictEqual(row.imageBase64, undefined, '不应再内联 base64'); const b64 = fav.getImage(db, row.id); assert.strictEqual(typeof b64, 'string'); assert.strictEqual(Buffer.compare(Buffer.from(b64, 'base64'), buf), 0); fav.close(db); }); check('getImage 对文本条目返回 null', () => { const dbPath2 = path.join(tmpHome, 'getimg-text.db'); const db = fav.open(dbPath2); const row = fav.add(db, { id: 201, type: 'text', text: 'x', preview: 'x', createdAt: Date.now()/1000 }); assert.strictEqual(fav.getImage(db, row.id), null); fav.close(db); }); check('count 返回条数且不加载 BLOB', () => { const dbPath2 = path.join(tmpHome, 'count.db'); const db = fav.open(dbPath2); assert.strictEqual(fav.count(db), 0); fav.add(db, { id: 301, type: 'text', text: 'a', preview: 'a', createdAt: Date.now()/1000 }); fav.add(db, { id: 302, type: 'image', image: Buffer.from([1,2,3]), preview: '[图片]', createdAt: Date.now()/1000 }); assert.strictEqual(fav.count(db), 2); fav.close(db); }); check('list 不内联图片数据', () => { const dbPath2 = path.join(tmpHome, 'list-noimg.db'); const db = fav.open(dbPath2); fav.add(db, { id: 401, type: 'image', image: Buffer.from([9,9,9]), preview: '[图片]', createdAt: Date.now()/1000 }); const rows = fav.list(db, '', 10); assert.strictEqual(rows.length, 1); assert.strictEqual(rows[0].hasImage, true); assert.strictEqual(rows[0].imageBase64, undefined); fav.close(db); }); check('isFavorited 返回 favId 或 null', () => { const dbPath2 = path.join(tmpHome, 'isfav.db'); const db = fav.open(dbPath2); assert.strictEqual(fav.isFavorited(db, 103), null, '未收藏'); fav.add(db, { id: 104, type: 'text', text: 'x', preview: 'x', createdAt: Date.now()/1000 }); const id = fav.isFavorited(db, 104); assert.ok(typeof id === 'number' && id > 0, '已收藏应返回数字 favId'); fav.close(db); }); function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } (async () => { check('remove 删除存在的 fav', () => { const dbPath2 = path.join(tmpHome, 'remove.db'); const db = fav.open(dbPath2); const row = fav.add(db, { id: 201, type: 'text', text: 'a', preview: 'a', createdAt: Date.now()/1000 }); assert.strictEqual(fav.remove(db, row.id), true); assert.strictEqual(fav.get(db, row.id), null); fav.close(db); }); check('remove 不存在的 fav 返回 false', () => { const dbPath2 = path.join(tmpHome, 'remove-missing.db'); const db = fav.open(dbPath2); assert.strictEqual(fav.remove(db, 99999), false); fav.close(db); }); check('get 取单条', () => { const dbPath2 = path.join(tmpHome, 'get.db'); const db = fav.open(dbPath2); const row = fav.add(db, { id: 202, type: 'text', text: 'b', preview: 'b', createdAt: Date.now()/1000 }); const got = fav.get(db, row.id); assert.strictEqual(got.text, 'b'); assert.strictEqual(got.id, row.id); fav.close(db); }); await check('list 默认按 favorited_at DESC', async () => { const dbPath2 = path.join(tmpHome, 'list-order.db'); const db = fav.open(dbPath2); fav.add(db, { id: 301, type: 'text', text: 'first', preview: 'first', createdAt: Date.now()/1000 }); await sleep(10); fav.add(db, { id: 302, type: 'text', text: 'second', preview: 'second', createdAt: Date.now()/1000 }); await sleep(10); fav.add(db, { id: 303, type: 'text', text: 'third', preview: 'third', createdAt: Date.now()/1000 }); const all = fav.list(db); assert.strictEqual(all.length, 3); assert.strictEqual(all[0].text, 'third'); assert.strictEqual(all[1].text, 'second'); assert.strictEqual(all[2].text, 'first'); fav.close(db); }); check('list 支持 limit', () => { const dbPath2 = path.join(tmpHome, 'list-limit.db'); const db = fav.open(dbPath2); for (let i = 0; i < 5; i++) fav.add(db, { id: 400+i, type: 'text', text: 'x'+i, preview: 'x'+i, createdAt: Date.now()/1000 }); assert.strictEqual(fav.list(db, '', 2).length, 2); fav.close(db); }); // getRaw:主进程「复制收藏到系统剪贴板」的取数路径(favorites:copy)。 // 必须带 image BLOB 且字段形状与 clips 表一致(type / text / image), // 才能直接喂 lib/clip-payload。 check('getRaw 取文本收藏的原始行', () => { const dbPath2 = path.join(tmpHome, 'getraw-text.db'); const db = fav.open(dbPath2); const row = fav.add(db, { id: 501, type: 'text', text: 'raw-text', preview: 'raw-text', createdAt: Date.now()/1000 }); const raw = fav.getRaw(db, row.id); assert.ok(raw, '应取到原始行'); assert.strictEqual(raw.type, 'text'); assert.strictEqual(raw.text, 'raw-text'); assert.strictEqual(raw.image, null); fav.close(db); }); check('getRaw 取图片收藏时带回 BLOB', () => { const dbPath2 = path.join(tmpHome, 'getraw-img.db'); const db = fav.open(dbPath2); const buf = Buffer.from([0x89, 0x50, 0x4E, 0x47, 7, 7]); const row = fav.add(db, { id: 502, type: 'image', image: buf, preview: '[图片]', createdAt: Date.now()/1000 }); const raw = fav.getRaw(db, row.id); assert.ok(raw, '应取到原始行'); assert.strictEqual(raw.type, 'image'); assert.ok(Buffer.isBuffer(raw.image), 'image 列应是 Buffer'); assert.strictEqual(Buffer.compare(raw.image, buf), 0, 'BLOB 内容应原样取回'); fav.close(db); }); check('getRaw 对不存在的 favId 返回 null', () => { const dbPath2 = path.join(tmpHome, 'getraw-missing.db'); const db = fav.open(dbPath2); assert.strictEqual(fav.getRaw(db, 99999), null); fav.close(db); }); // ---------- 搜索 ---------- // 与历史搜索同语义(lib/search-plan):多 token AND、% _ \ 转义。 check('list 搜索:LIKE 元字符按字面匹配(% _ 不是通配符)', () => { const dbPath2 = path.join(tmpHome, 'search-esc.db'); const db = fav.open(dbPath2); fav.add(db, { id: 601, type: 'text', text: '100% off_sale promo', preview: '', createdAt: Date.now()/1000 }); fav.add(db, { id: 602, type: 'text', text: '100 apples for sale', preview: '', createdAt: Date.now()/1000 }); fav.add(db, { id: 603, type: 'text', text: 'aXb wildcard victim', preview: '', createdAt: Date.now()/1000 }); assert.strictEqual(fav.list(db, '100%').length, 1, '% 不能当通配符匹配一切以 100 开头的内容'); assert.strictEqual(fav.list(db, 'off_sale').length, 1, '_ 不能匹配任意单字符'); assert.strictEqual(fav.list(db, 'a_b').length, 0, '没有字面含 a_b 的记录'); fav.close(db); }); check('list 搜索:多 token 是 AND、顺序无关', () => { const dbPath2 = path.join(tmpHome, 'search-and.db'); const db = fav.open(dbPath2); fav.add(db, { id: 611, type: 'text', text: 'walk in the park', preview: '', createdAt: Date.now()/1000 }); fav.add(db, { id: 612, type: 'text', text: 'park bench only', preview: '', createdAt: Date.now()/1000 }); assert.strictEqual(fav.list(db, 'park walk').length, 1, '两个 token 都出现才命中'); assert.strictEqual(fav.list(db, 'walk park').length, 1, '顺序无关'); assert.strictEqual(fav.list(db, 'park missing').length, 0, '缺一个就不命中'); fav.close(db); }); console.log(`\n${passed} checks passed`); })();