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

247 lines
10 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';
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 之后
// 失败会变成 unhandledRejectionsummary 还先把这条计进了 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`);
})();