This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
'use strict';
const assert = require('node:assert');
const { computePollMs } = require('../lib/adaptive-poll.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; }
}
check('visible returns base interval', () => {
assert.strictEqual(computePollMs(1000, true), 1000);
});
check('hidden doubles the interval, capped at 4s', () => {
assert.strictEqual(computePollMs(500, false), 1000);
assert.strictEqual(computePollMs(1000, false), 2000);
assert.strictEqual(computePollMs(2000, false), 4000);
assert.strictEqual(computePollMs(2000, false, { cap: 3000 }), 3000);
});

View File

@@ -0,0 +1,62 @@
'use strict';
// 回归测试:选择与当前相同目录的不同大小写形式,应当判为"已是当前数据位置"。
//
// 被测对象是 lib/same-dir.js —— main.jssettings:choose-dir与 config.js
// migrateLegacyData共用它。之前本测试自己内联了一份实现来测而 main.js
// 实际用的是原始 `===`Windows 大小写不一致时误判为不同目录,自己迁自己),
// 测试却一路绿灯 —— 内联副本与生产代码脱节。现在直接测共享逻辑本体。
const assert = require('node:assert');
const path = require('node:path');
const { isSameDir } = require('../lib/same-dir');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('Windows 同目录不同大小写:应判为相同', () => {
if (process.platform !== 'win32') {
console.log(' (skipped on non-Windows)');
return;
}
assert.strictEqual(
isSameDir('C:\\Users\\Foo\\.clipboard-app', 'c:\\users\\foo\\.clipboard-app'),
true
);
});
check('Windows 反斜杠/正斜杠混用:应判为相同', () => {
if (process.platform !== 'win32') {
console.log(' (skipped on non-Windows)');
return;
}
// path.resolve 会统一为反斜杠
const a = path.resolve('C:/Users/Foo/.clipboard-app');
const b = path.resolve('C:\\Users\\Foo\\.clipboard-app');
assert.strictEqual(isSameDir(a, b), true);
});
check('Windows 不同盘符:应判为不同', () => {
if (process.platform !== 'win32') {
console.log(' (skipped on non-Windows)');
return;
}
assert.strictEqual(
isSameDir('C:\\foo', 'D:\\foo'),
false
);
});
check('POSIX 严格比较(不同大小写应判为不同)', () => {
if (process.platform === 'win32') {
console.log(' (skipped on Windows)');
return;
}
assert.strictEqual(
isSameDir('/home/Foo/.clipboard-app', '/home/foo/.clipboard-app'),
false
);
});
console.log(`\n${passed} checks passed`);

177
test/clip-limit.test.js Normal file
View File

@@ -0,0 +1,177 @@
'use strict';
/**
* 入库绑定 + 限额清理:应用里唯一会主动删用户数据的两条路径。
*
* 起因2026-07-31 体检insertText / insertImage 的调用把 `now` 绑到了
* top_at 列(占位符第 3 个是 top_at导致每条新记录都"被置顶"——
* trimOldest / clearNonTop 只删 top_at IS NULL 的行,于是限额清理和
* 「清空历史」在会话内全部静默失效history.db 可无限膨胀。启动时会清空
* 所有 top_at所以重启后"看起来正常",藏了很久。
*
* main.js 不能脱离 Electron 加载,这里两层守:
* 1. 静态:插入调用的第 3 个实参必须是 nulltop_at
* 2. 行为:把 main.js 源码里的 trimOldest / clearNonTop / INSERT **原样
* 提取出来**在真 SQLite 上跑 —— 测的是生产 SQL 本身,不是克隆副本。
*/
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 mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
// ---------- 静态:插入调用必须给 top_at 绑 null ----------
check('insertText 调用给 top_at 绑 null不是 now', () => {
assert.match(
mainSrc,
/stmt\.insertText\.run\(\s*text\s*,\s*makePreview\(text\)\s*,\s*null\s*,\s*now\s*\)/,
'insertText.run 的第 3 个实参top_at 占位符)必须是 null —— 绑 now 会让每条新记录被当成置顶,限额清理全废'
);
});
check('insertImage 调用给 top_at 绑 null不是 now', () => {
assert.match(
mainSrc,
/stmt\.insertImage\.run\(\s*resized\s*,\s*preview\s*,\s*null\s*,\s*now\s*\)/,
'insertImage.run 的第 3 个实参top_at 占位符)必须是 null'
);
});
// ---------- 提取生产 SQL ----------
function extractSql(anchor) {
const re = new RegExp(`stmt\\.${anchor}\\s*=\\s*db\\.prepare\\(\\s*\`([\\s\\S]*?)\`\\s*\\)`);
const m = mainSrc.match(re);
assert.ok(m, `main.js 里找不到 stmt.${anchor} 的 prepare`);
return m[1];
}
const INSERT_TEXT = extractSql('insertText');
const TRIM_OLDEST = extractSql('trimOldest');
const CLEAR_NON_TOP = extractSql('clearNonTop');
const MOVE_TO_TOP = extractSql('moveToTop');
check('INSERT 语句的占位符顺序确实是 (text, preview, top_at, created_at)', () => {
// 静态断言成立的前提占位符顺序变了而调用没跟着变null 会落到别的列
assert.match(INSERT_TEXT, /INSERT INTO clips\(\s*type\s*,\s*text\s*,\s*preview\s*,\s*top_at\s*,\s*created_at\s*\)/);
assert.match(INSERT_TEXT, /VALUES\(\s*'text'\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*\)/);
});
check('trimOldest 只删 top_at IS NULL、按 created_at ASC', () => {
assert.match(TRIM_OLDEST, /top_at IS NULL/, '少了 top_at IS NULL 会删掉用户置顶项');
assert.match(TRIM_OLDEST, /ORDER BY created_at ASC/, '删的顺序必须是最旧优先');
});
// ---------- 行为:拿提取出来的 SQL 在真库上跑 ----------
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-limit-'));
const db = new Database(path.join(tmp, 'history.db'));
db.exec(`
CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text TEXT,
image BLOB,
preview TEXT,
top_at REAL,
created_at REAL NOT NULL
)
`);
return db;
}
// 与 main.js 现在一致的绑定方式:(text, preview, top_at=null, created_at)
function seedNatural(db, n) {
const ins = db.prepare(INSERT_TEXT);
for (let i = 1; i <= n; i++) ins.run(`clip-${i}`, `clip-${i}`, null, i);
}
check('新插入的记录 top_at 为 NULL自然位置', () => {
const db = makeDb();
seedNatural(db, 3);
const rows = db.prepare('SELECT id, top_at FROM clips').all();
assert.strictEqual(rows.length, 3);
for (const r of rows) assert.strictEqual(r.top_at, null, `id=${r.id} 不应被置顶`);
db.close();
});
check('超限清理:只删最旧的自然项,置顶项不动', () => {
const db = makeDb();
seedNatural(db, 5); // created_at 1..5
db.prepare('UPDATE clips SET top_at = 100 WHERE created_at IN (4, 5)').run();
const trim = db.prepare(TRIM_OLDEST);
const r = trim.run(2); // 5 条保留 3 条 → 删 2
assert.strictEqual(r.changes, 2);
const left = db.prepare('SELECT created_at FROM clips ORDER BY created_at').all().map(x => x.created_at);
assert.deepStrictEqual(left, [3, 4, 5], '应删掉最旧的两条自然项1、2置顶的 4、5 保留');
db.close();
});
check('过量删除也碰不到置顶项', () => {
const db = makeDb();
seedNatural(db, 5);
db.prepare('UPDATE clips SET top_at = 100 WHERE created_at IN (4, 5)').run();
const r = db.prepare(TRIM_OLDEST).run(99);
assert.strictEqual(r.changes, 3, '只能删掉 3 条自然项');
const left = db.prepare('SELECT created_at FROM clips ORDER BY created_at').all().map(x => x.created_at);
assert.deepStrictEqual(left, [4, 5], '置顶项必须全部幸存');
db.close();
});
check('clearNonTop清空历史同样只动自然项', () => {
const db = makeDb();
seedNatural(db, 4);
db.prepare('UPDATE clips SET top_at = 100 WHERE created_at = 4').run();
const r = db.prepare(CLEAR_NON_TOP).run();
assert.strictEqual(r.changes, 3);
const left = db.prepare('SELECT created_at FROM clips').all().map(x => x.created_at);
assert.deepStrictEqual(left, [4], '只剩置顶的那条');
db.close();
});
check('置顶是会话级的:启动清顶后这些行又能被正常清理', () => {
const db = makeDb();
seedNatural(db, 3);
db.prepare('UPDATE clips SET top_at = 100').run();
// 模拟 main.js openDb 里的启动清顶
db.prepare('UPDATE clips SET top_at = NULL WHERE top_at IS NOT NULL').run();
const r = db.prepare(TRIM_OLDEST).run(3);
assert.strictEqual(r.changes, 3, '清顶后所有行都回到自然位置,可被删');
db.close();
});
check('moveToTop 同时更新 top_at 和 created_at让点过的项保持「最近活跃」', () => {
// 用户点过复制的项要排到顶 —— 单纯更新 top_at 不够created_at 也得一起
// bump 到 now否则列表排序ORDER BY created_at DESC看不到这次活跃。
assert.match(MOVE_TO_TOP, /UPDATE\s+clips\s+SET\s+top_at\s*=\s*\?\s*,\s*created_at\s*=\s*\?\s+WHERE\s+id\s*=\s*\?/,
'moveToTop 必须同时更新 top_at 和 created_at');
});
check('moveToTop 把 created_at bump 到 now —— 已点项重新活跃,排到顶', () => {
const db = makeDb();
seedNatural(db, 3); // created_at 1..3
const move = db.prepare(MOVE_TO_TOP);
// 点 id=1 的「复制」:把它的 top_at / created_at 都置到 1000
move.run(1000, 1000, 1);
// 用新排序取 → 刚被点过的 id=1 必须在最顶
const rows = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC`).all();
assert.deepStrictEqual(rows.map(r => r.id), [1, 3, 2],
'点过的项created_at=1000必须排在原始 created_at=3、2 的前面');
// 而且 top_at 也被置上了trim / 清空历史 仍会跳过它
const r1 = db.prepare('SELECT top_at, created_at FROM clips WHERE id = 1').get();
assert.strictEqual(r1.top_at, 1000);
assert.strictEqual(r1.created_at, 1000);
db.close();
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,91 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const rendererSrc = fs.readFileSync(
path.join(__dirname, '..', 'renderer', 'renderer.js'), 'utf8'
);
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; }
}
// ---------- buildCard 抽函数 ----------
check('renderer.js 定义了 buildCard 函数', () => {
assert.ok(
/function\s+buildCard\s*\(/.test(rendererSrc),
'找不到 buildCard(c, i) 定义'
);
});
check('render() 调用 buildCard 而不是手写卡片构建', () => {
const renderFn = rendererSrc.match(/function\s+render\s*\(\s*\)\s*\{[\s\S]*?\n\}/);
assert.ok(renderFn, '找不到 render()');
assert.ok(
/buildCard\s*\(/.test(renderFn[0]),
'render() 没调用 buildCard'
);
// 抽函数后render() 内不应该有 cloneNode(true) 这种属于 buildCard 的细节
assert.ok(
!/cloneNode\s*\(\s*true\s*\)/.test(renderFn[0]),
'render() 还在做 cloneNodebuildCard 抽得不够彻底'
);
});
// ---------- onAdded 增量插入路径 ----------
check('onAdded 在 history Tab + 无搜索时走 prepend 而非 reload', () => {
const onAdded = rendererSrc.match(/onAdded\s*\(\s*\(\s*\w+\s*\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(onAdded, '找不到 onAdded 回调');
// 存在非 reload 的快路径增量插入。reload 只应出现在跳过分支(收藏 Tab/
// 搜索中)和 catch 兜底里 —— 最多 2 次。快路径由下一条断言insertBefore/
// unshift单独保证。原计划写死 <=1 与 spec 强制的 catch fallback 冲突,
// 这里修正为 <=2 以匹配 spec见 task-7 报告的 controller addendum
const reloadCount = (onAdded[0].match(/\breload\s*\(/g) || []).length;
assert.ok(reloadCount <= 2, `onAdded 内 reload 次数 ${reloadCount},应是跳过分支 + catch 兜底 ≤ 2 次`);
// 应该有 prepend 路径或 append 增量逻辑
assert.ok(
/insertBefore|prepend|unshift/.test(onAdded[0]),
'onAdded 没走增量插入路径(看不到 insertBefore / unshift'
);
});
check('onAdded 跳过条件:搜索框非空或非 history Tab', () => {
const onAdded = rendererSrc.match(/onAdded\s*\(\s*\(\s*\w+\s*\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(onAdded, '找不到 onAdded 回调');
assert.ok(
/currentTab\s*!==\s*'history'/.test(onAdded[0]),
'onAdded 没判断非 history Tab 时跳过 prepend'
);
assert.ok(
/\$search\.value\.trim\s*\(\s*\)\s*!==\s*''/.test(onAdded[0]),
'onAdded 没判断搜索框非空时跳过 prepend'
);
});
// ---------- 增量插入的两个回归坑 ----------
check('prependClip 熄灭空状态(#empty', () => {
// 空列表时 render() 亮起 #empty第一条增量不走 render()
// 不主动隐藏就会出现"卡片 + 暂无内容"同屏。
const prepend = rendererSrc.match(/function\s+prependClip\s*\([\s\S]*?\n\}/);
assert.ok(prepend, '找不到 prependClip()');
assert.ok(
/\$empty\.classList\.add\(\s*'hidden'\s*\)/.test(prepend[0]),
'prependClip 没有隐藏 #empty —— 空列表收到第一条增量时空状态不熄灭'
);
});
check('buildCard 的点击选中按运行时下标,不捕获构建时 i', () => {
// prependClip 会把已有卡片整体后移,捕获构建时下标的闭包会选中错误的项。
assert.ok(
/node\.onclick\s*=\s*\(\)\s*=>\s*select\(\s*\$cards\.indexOf\(node\)\s*\)/.test(rendererSrc),
'node.onclick 应在点击时从 $cards 现取下标select($cards.indexOf(node))),而不是捕获构建时的 i'
);
});
console.log(`\n${passed} checks passed`);

64
test/clip-payload.test.js Normal file
View File

@@ -0,0 +1,64 @@
'use strict';
/**
* 回归测试:图片条目复制到剪贴板
*
* 曾经的 bugcopyClipToClipboard 先 rowToClip(row) 再读 clip.image
* 但 rowToClip 产出的字段叫 imageBase64没有 .image。
* Buffer.from(undefined) 抛 TypeError被 catch 吞掉 —— 所有图片记录
* 的"复制"都静默失败,用户按 Ctrl+V 粘到的是上一次剪贴板里的东西。
*/
const assert = require('node:assert');
const { clipPayload } = require('../lib/clip-payload.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; }
}
check('text row yields its text', () => {
const p = clipPayload({ type: 'text', text: 'hello', image: null });
assert.deepStrictEqual(p, { kind: 'text', text: 'hello' });
});
check('text row with null text yields empty string, not crash', () => {
assert.deepStrictEqual(clipPayload({ type: 'text', text: null }), { kind: 'text', text: '' });
});
check('image row yields the raw BLOB as a Buffer', () => {
const blob = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const p = clipPayload({ type: 'image', text: null, image: blob });
assert.strictEqual(p.kind, 'image');
assert.ok(Buffer.isBuffer(p.buffer));
assert.deepStrictEqual([...p.buffer], [0x89, 0x50, 0x4e, 0x47]);
});
check('image row survives a Uint8Array (what sqlite may hand back)', () => {
const p = clipPayload({ type: 'image', image: new Uint8Array([1, 2, 3]) });
assert.ok(Buffer.isBuffer(p.buffer));
assert.deepStrictEqual([...p.buffer], [1, 2, 3]);
});
// 这条正是当年的失败模式:把 rowToClip 的产物喂进来(只有 imageBase64没有 image
check('a row missing the image column throws a clear error, not a TypeError', () => {
const rowToClipShaped = { type: 'image', text: '', imageBase64: 'iVBORw0KGgo=', preview: '[图片]' };
assert.throws(() => clipPayload(rowToClipShaped), (e) => {
assert.ok(!(e instanceof TypeError), '不应是 Buffer.from(undefined) 那种 TypeError');
assert.match(e.message, /图片数据为空/);
return true;
});
});
check('empty image blob is rejected', () => {
assert.throws(() => clipPayload({ type: 'image', image: Buffer.alloc(0) }), /图片数据为空/);
});
check('missing row is rejected', () => {
assert.throws(() => clipPayload(null), /记录不存在/);
});
check('unknown type is rejected', () => {
assert.throws(() => clipPayload({ type: 'video' }), /未知的记录类型/);
});
console.log(`\n${passed} checks passed`);

110
test/config.test.js Normal file
View File

@@ -0,0 +1,110 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// 用临时目录隔离,避免污染真实配置。
// 默认数据目录现在是"项目根/data",不再跟 HOME 走 —— 必须用
// CLIPBOARD_APP_DATA_DIR 覆盖,否则测试会写进仓库里的 data/。
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-cfg-'));
process.env.USERPROFILE = tmpHome;
process.env.HOME = tmpHome;
process.env.CLIPBOARD_APP_DATA_DIR = path.join(tmpHome, 'data');
const cfg = require('../config.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('defaults 结构完整', () => {
assert.strictEqual(cfg.DEFAULTS.autoLaunch, false);
assert.strictEqual(cfg.DEFAULTS.maxItems, 0);
assert.strictEqual(cfg.DEFAULTS.displayLimit, 300);
assert.strictEqual(cfg.DEFAULTS.pollMs, 1000);
assert.strictEqual(cfg.DEFAULTS.hotkey, 'Ctrl+Alt+V');
assert.ok(typeof cfg.DEFAULTS.dataDir === 'string' && cfg.DEFAULTS.dataDir.length > 0);
});
check('默认数据目录不是 ~/.clipboard-app且被 CLIPBOARD_APP_DATA_DIR 覆盖', () => {
// 不去硬编码 'D:\\Clipboard Data':那是个环境相关的常量,谁换机就炸。
// 这里要测的是两条契约1) 不再是 home 下的 .clipboard-app2) 环境变量能覆盖。
assert.notStrictEqual(path.resolve(cfg.defaultDataDir()), path.resolve(cfg.legacyDataDir()));
assert.strictEqual(path.resolve(cfg.defaultDataDir()), path.resolve(tmpHome, 'data'));
// config.json 跟着默认目录走
assert.strictEqual(path.dirname(cfg.getConfigPath()), path.resolve(cfg.defaultDataDir()));
});
check('默认数据目录D 盘可用时首选 D 盘', () => {
// 默认目录的契约有两个 caseD 盘存在 → 首选 D:\Clipboard Data
// 不存在 → 降级到 home 下的 Clipboard Data。这条只测前者。
const saved = process.env.CLIPBOARD_APP_DATA_DIR;
delete process.env.CLIPBOARD_APP_DATA_DIR;
delete require.cache[require.resolve('../config.js')];
const cfg2 = require('../config.js');
// D 盘在 CI / 开发机上不一定存在,强行断言会让套件不可移植。
// 这里只断言「D 盘可用时返回 D 盘」:先探测一次,能用才断言。
process.env.CLIPBOARD_APP_DATA_DIR = saved;
if (cfg2.isDirUsable('D:\\Clipboard Data')) {
assert.strictEqual(path.resolve(cfg2.defaultDataDir()), path.resolve('D:\\Clipboard Data'));
} else {
// 当前机器 D 盘不可用 → 跳过本断言fallback case 在 data-dir-fallback.test.js
console.log(' (D:\\Clipboard Data 不可用,跳过 — 见 data-dir-fallback.test.js)');
}
});
check('缺文件时 loadConfig 返回全默认', () => {
const c = cfg.loadConfig();
assert.strictEqual(c.maxItems, 0);
assert.strictEqual(c.displayLimit, 300);
assert.strictEqual(c.pollMs, 1000);
assert.strictEqual(c.autoLaunch, false);
});
check('sanitize 回落非法枚举值', () => {
const c = cfg.sanitize({ maxItems: 999, displayLimit: 42, pollMs: 17, autoLaunch: 'yes', dataDir: 42 });
assert.strictEqual(c.maxItems, 0);
assert.strictEqual(c.displayLimit, 300);
assert.strictEqual(c.pollMs, 1000);
assert.strictEqual(c.autoLaunch, false);
assert.strictEqual(typeof c.dataDir, 'string');
});
check('sanitize hotkey非法值回落默认合法预设保留空串 = 禁用不算非法', () => {
// 自由组合键不认 —— 设置界面只给预设档位,白名单外的一律回落
assert.strictEqual(cfg.sanitize({ hotkey: 'Ctrl+Shift+F13' }).hotkey, 'Ctrl+Alt+V');
assert.strictEqual(cfg.sanitize({ hotkey: 42 }).hotkey, 'Ctrl+Alt+V');
assert.strictEqual(cfg.sanitize({ hotkey: 'Alt+Shift+V' }).hotkey, 'Alt+Shift+V');
// '' 是明确的「禁用」语义,绝不能回落默认 —— 否则禁不掉
assert.strictEqual(cfg.sanitize({ hotkey: '' }).hotkey, '');
// 老配置没有 hotkey 字段(升级场景)→ 默认,等于自动获得快捷键
assert.strictEqual(cfg.sanitize({}).hotkey, 'Ctrl+Alt+V');
// 默认值本身必须在白名单里(否则首次启动的 register 必炸)
assert.ok(cfg.ALLOWED_HOTKEYS.includes(cfg.DEFAULTS.hotkey));
// 白名单里的每一个值都得能通过自己(防 ALLOWED_HOTKEYS 与默认值写岔)
for (const k of cfg.ALLOWED_HOTKEYS) {
assert.strictEqual(cfg.sanitize({ hotkey: k }).hotkey, k, `白名单成员 ${JSON.stringify(k)} 应通过校验`);
}
});
check('save 后 load 往返一致', () => {
cfg.saveConfig({ autoLaunch: true, maxItems: 1000, displayLimit: 500, pollMs: 500, dataDir: tmpHome, hotkey: 'Ctrl+Alt+C' });
const c = cfg.loadConfig();
assert.strictEqual(c.autoLaunch, true);
assert.strictEqual(c.maxItems, 1000);
assert.strictEqual(c.displayLimit, 500);
assert.strictEqual(c.pollMs, 500);
assert.strictEqual(c.dataDir, tmpHome);
assert.strictEqual(c.hotkey, 'Ctrl+Alt+C');
});
check('坏 JSON 不抛异常,返回默认', () => {
fs.writeFileSync(cfg.getConfigPath(), '{ this is not json ');
const c = cfg.loadConfig();
assert.strictEqual(c.maxItems, 0);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,141 @@
'use strict';
/**
* 数据目录降级测试 —— 解决「默认 D:\Clipboard Data 不可用就启动失败」的兼容问题。
*
* 被测对象是 config.js 的 isDirUsable / defaultDataDir。三条契约
* 1. isDirUsable 能正确判断目录真的能写入;
* 2. defaultDataDir 在默认目录不可用时降级到 home 下的 Clipboard Data
* 3. 探针文件不会残留(每次探测都自清理)。
*
* 模拟「D 盘不存在」的方法:临时替换 fs.mkdirSync让它对 D:\\ 开头的路径
* 抛 ENOENT —— 跟项目里「不引入 mock 框架、靠临时替换内置模块方法」的风格
* 一致。restore 严格用 finally / afterEach 兜住,避免污染其它测试。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
// 用临时 HOME 隔离 —— defaultDataDir() 降级时是 %USERPROFILE%\Clipboard Data
// 我们希望探针 / 副作用都落在临时目录,不污染真实 home。
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-fb-'));
process.env.USERPROFILE = tmpHome;
process.env.HOME = tmpHome;
// 不预设 CLIPBOARD_APP_DATA_DIR —— 本测试就是要验证它没设时的降级行为
delete process.env.CLIPBOARD_APP_DATA_DIR;
const cfg = require('../config.js');
// 「D 盘不存在」的 mockfs.mkdirSync 对 D:\ 前缀抛 ENOENT。
// 不影响其它调用 —— D: 在大多数机器上不一定存在,我们的判断「按前缀」
// 已经足够准;同时不影响 home 下的真实写入。
const originalMkdir = fs.mkdirSync;
function mockNoDDrive() {
fs.mkdirSync = function patched(p, opts) {
if (typeof p === 'string' && /^[dD]:[\\/]/.test(p)) {
const e = new Error(`ENOENT: no such file or directory, mkdir '${p}'`);
e.code = 'ENOENT';
throw e;
}
return originalMkdir.call(fs, p, opts);
};
}
function restoreFs() {
fs.mkdirSync = originalMkdir;
}
process.on('exit', restoreFs);
check('isDirUsable: 真实可写目录返回 true', () => {
// tmpHome 必然可写
assert.strictEqual(cfg.isDirUsable(tmpHome), true);
});
check('isDirUsable: 不存在的盘符D:\\__no_such__在 mock 下返回 false', () => {
mockNoDDrive();
try {
assert.strictEqual(cfg.isDirUsable('D:\\__no_such_drive_xyz__'), false);
} finally { restoreFs(); }
});
check('isDirUsable: 空字符串返回 false不打 fs.mkdirSync 空路径)', () => {
assert.strictEqual(cfg.isDirUsable(''), false);
assert.strictEqual(cfg.isDirUsable(null), false);
assert.strictEqual(cfg.isDirUsable(undefined), false);
});
check('isDirUsable: 探针文件被清理,不在目录里残留', () => {
// 探针名字固定 —— 跑两次探测,第二次还能成功说明第一次 unlink 了。
const probePath = path.join(tmpHome, '.clipboard-write-test');
assert.ok(!fs.existsSync(probePath), '开始前不应有残留');
assert.strictEqual(cfg.isDirUsable(tmpHome), true);
assert.ok(!fs.existsSync(probePath), '探测后不应残留探针文件');
assert.strictEqual(cfg.isDirUsable(tmpHome), true, '第二次探测照样过 → 第一次 unlink 干净');
});
check('defaultDataDir: D 盘不可用时降级到 home 下的 Clipboard Data', () => {
mockNoDDrive();
try {
// 重新加载模块,让 defaultDataDir() 重新走一遍降级逻辑
delete require.cache[require.resolve('../config.js')];
const cfg2 = require('../config.js');
const dir = cfg2.defaultDataDir();
// 降级路径必须等于 home + Clipboard Data
assert.strictEqual(
path.resolve(dir),
path.resolve(tmpHome, 'Clipboard Data'),
'D 盘被 mock 掉时必须降级到 home\\Clipboard Data'
);
// 降级目录真的可用 —— 否则还是炸
assert.strictEqual(cfg2.isDirUsable(dir), true);
} finally {
restoreFs();
delete require.cache[require.resolve('../config.js')];
require('../config.js'); // 重新挂回正常状态给后面的 case
}
});
check('defaultDataDir: env 覆盖在降级链路里仍最高优先级', () => {
mockNoDDrive();
const custom = path.join(tmpHome, 'env-override-dir');
process.env.CLIPBOARD_APP_DATA_DIR = custom;
try {
delete require.cache[require.resolve('../config.js')];
const cfg2 = require('../config.js');
assert.strictEqual(path.resolve(cfg2.defaultDataDir()), path.resolve(custom),
'env 覆盖必须胜过 D 盘 / home 的降级选择');
} finally {
restoreFs();
delete process.env.CLIPBOARD_APP_DATA_DIR;
delete require.cache[require.resolve('../config.js')];
require('../config.js');
}
});
check('defaultDataDir: D 盘可用时首选 D 盘(契约保留)', () => {
// 不 mock —— 用真实环境。仅当 D 盘真的可用时断言,否则跳过。
if (!cfg.isDirUsable('D:\\Clipboard Data')) {
console.log(' (D:\\Clipboard Data 在本机不可用,跳过)');
return;
}
// 不设 env 时必须首选 D 盘
const saved = process.env.CLIPBOARD_APP_DATA_DIR;
delete process.env.CLIPBOARD_APP_DATA_DIR;
delete require.cache[require.resolve('../config.js')];
try {
const cfg2 = require('../config.js');
assert.strictEqual(path.resolve(cfg2.defaultDataDir()), path.resolve('D:\\Clipboard Data'));
} finally {
process.env.CLIPBOARD_APP_DATA_DIR = saved;
delete require.cache[require.resolve('../config.js')];
require('../config.js');
}
});
// 清理
fs.rmSync(tmpHome, { recursive: true, force: true });
console.log(`\n${passed} checks passed`);

44
test/db-prepared.test.js Normal file
View File

@@ -0,0 +1,44 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-stmt-'));
process.env.USERPROFILE = tmp;
process.env.HOME = tmp;
const Database = require('better-sqlite3');
const dbPath = path.join(tmp, 'history.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, text TEXT, image BLOB, preview TEXT,
top_at REAL, created_at REAL NOT NULL
);
`);
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; }
}
const cached = {
insert: db.prepare('INSERT INTO clips(type, text, preview, top_at, created_at) VALUES(?, ?, ?, ?, ?)'),
};
check('prepare reuse returns same statement object (sanity)', () => {
const a = db.prepare('SELECT 1');
const b = db.prepare('SELECT 1');
assert.ok(a !== b, 'sanity: each prepare() returns new object');
});
check('cached insert works repeatedly', () => {
for (let i = 0; i < 5; i++) {
cached.insert.run('text', `m${i}`, `p${i}`, null, Date.now()/1000);
}
const n = db.prepare('SELECT COUNT(*) AS n FROM clips').get().n;
assert.strictEqual(n, 5);
});

182
test/echo-suppress.test.js Normal file
View File

@@ -0,0 +1,182 @@
'use strict';
/**
* 回声抑制状态机lib/echo-suppress.js—— 测的是 main.js 实际引用的本体。
*
* 这份逻辑以前内联在 main.js 里旧测试poll-suppress.test.js克隆了一份
* 状态机自己测自己main.js 改坏了测试照样绿,而这是丢数据级的逻辑
* (吞错一次 = 用户的一次真实复制静默消失)。现在抽到 lib测本体。
*
* 每个用例对应一条真实踩坑规则,注释里写了放掉会发生什么。
*/
const assert = require('node:assert');
const { createEchoSuppress } = require('../lib/echo-suppress.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('启动采样:基线内容第一次见到不算新', () => {
const s = createEchoSuppress();
s.seed('已经在剪贴板里的旧内容');
assert.strictEqual(s.observe('已经在剪贴板里的旧内容'), 'unchanged');
// 放开这条:每次重启都会把剪贴板里的旧内容重复入库一次
assert.strictEqual(s.observe('外部新复制'), 'new');
});
check('自己写进去的回声:下一 tick 不会把它当新内容', () => {
const s = createEchoSuppress();
s.markOwnWrite('A'); // pending=A同时基线预置为 A
// 读到的回声与预置基线一致 → 'unchanged'(同样不入库)。
// suppressed 分支出现在基线与 pending 分叉之后(见下条)。
assert.strictEqual(s.observe('A'), 'unchanged');
assert.strictEqual(s.observe('A'), 'unchanged');
});
check('suppressed 分支:基线与 pending 分叉后,匹配 pending 的内容被吞', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
// 外部中途清空剪贴板:基线变 ''pending 不被消化
assert.strictEqual(s.observe(''), 'empty');
// 此后读到与 pending 一致的内容 → suppressed设计取舍见 lib 头注释:
// 宁可漏掉"清空后外部再复制同内容",也不让回声重复入库)
assert.strictEqual(s.observe('A'), 'suppressed');
});
check('回声被外部复制顶掉:外部那次必须入库(占位 flag 实现会在这里丢数据)', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
// 两次 tick 之间用户在外面复制了 Btick 直接读到 B
assert.strictEqual(s.observe('B'), 'new', '外部复制 B 不能被回声标记吞掉');
});
check('不匹配的新内容消化 pending之后相同内容的外部复制不被误伤', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
assert.strictEqual(s.observe('B'), 'new');
// 用户又复制回 A外部—— pending 已被 B 消化A 正常入库
// 放开这条:点复制 A → 外部 B → 外部再复制 A第二个 A 被静默吞掉
assert.strictEqual(s.observe('A'), 'new');
});
check('剪贴板被清空不消化 pending清空后复制同内容仍被识别为回声', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
assert.strictEqual(s.observe(''), 'empty');
// 外部中途清空剪贴板不该让回声标记失效,否则紧接着读到 A 会重复入库
assert.strictEqual(s.observe('A'), 'suppressed');
});
check('基线已是空串再读空unchanged 而不是 empty保住 main.js 的图片分支)', () => {
const s = createEchoSuppress();
s.seed(''); // 启动时剪贴板没有文本 —— 纯图片场景的常态
assert.strictEqual(s.observe(''), 'unchanged');
// 放开这条(把 empty 判断挪到 unchanged 之前):文本恒空时每个 tick 都在
// 文本分支早退main.js 永远走不到图片分支 —— 用户复制图片整条静默丢失。
assert.strictEqual(s.observe('外部复制'), 'new');
});
check('写入失败清标记:失败后用户的真实复制不被吞', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
s.clearPending(); // 只清 pending不动基线
assert.strictEqual(s.observe(''), 'empty'); // 让基线与已清的pending 分叉
// pending 已清 → 读 A 走 'new' 而不是 'suppressed',真实复制不丢
assert.strictEqual(s.observe('A'), 'new');
});
check('写入失败回滚基线:失败后真实旧内容不被当新内容重复入库', () => {
const s = createEchoSuppress();
s.seed('旧内容(已在库里)');
const undo = s.markOwnWrite('新内容'); // 模拟点"复制":预置基线 + 记回声
undo(); // 模拟 clipboard.writeText 抛错后回滚
// 剪贴板上其实还是旧内容,下个 tick 读到它必须判 unchanged。
// 放开这条markOwnWrite 不提供回滚):基线停在'新内容',下个 tick 读到
// 真实旧内容判 'new',早已在库里的旧内容被重复插入一条。
assert.strictEqual(s.observe('旧内容(已在库里)'), 'unchanged');
// 回滚同时清了回声标记:用户之后真的复制了那个内容,照常入库不被吞
assert.strictEqual(s.observe('新内容'), 'new');
});
check('基线随每次变化更新:重复读同内容不重复入库', () => {
const s = createEchoSuppress();
assert.strictEqual(s.observe('X'), 'new');
assert.strictEqual(s.observe('X'), 'unchanged');
assert.strictEqual(s.observe('X'), 'unchanged');
assert.strictEqual(s.observe('Y'), 'new');
});
check('resetBaseline1×1 空图之后任何真图都算新', () => {
const s = createEchoSuppress();
s.seed('hash-of-small');
s.resetBaseline();
assert.strictEqual(s.observe('hash-of-real-image'), 'new');
});
check('abortLastObserve入库失败回滚后同一条内容能再次入库', () => {
// 用户报告的"卡住"场景observe 判 'new' → 入库失败SQLite 临时锁、
// 磁盘满、字段非法等)→ 下次 tick 读到同内容判 'unchanged' → 永久吞掉。
// abortLastObserve 必须撤销 observe 对基线的修改,让下次 tick 再判 'new'。
const s = createEchoSuppress();
s.seed('之前的内容');
assert.strictEqual(s.observe('新内容'), 'new', '第一次观察应判 new');
// 模拟 stmt.insertText 抛错:入库失败方在 catch 里调 abortLastObserve
s.abortLastObserve();
// 下次 tick 读到同样内容 → 必须重新判 'new',让 pollClipboard 再试入库
assert.strictEqual(s.observe('新内容'), 'new', 'abort 后应允许重试,不该被吞');
// 这次入库成功后基线更新,下次再读 → 'unchanged',正常路径
assert.strictEqual(s.observe('新内容'), 'unchanged', '入库成功后基线已建立');
});
check('abortLastObserve同时清掉 pending之前 markOwnWrite 留下的回声标记)', () => {
// 极小概率但可能:上一次 tick 已经 markOwnWrite自己写入留了 pending
// 紧接着入库失败 abort —— 这时 pending 是上次的回声标记,不该在 abort
// 后继续生效(否则下次 tick 会把外部真实复制当回声吞掉)。
const s = createEchoSuppress();
s.markOwnWrite('A'); // pending='A', last='A'
s.observe('B'); // 'new'pending 被消化)
s.markOwnWrite('C'); // pending='C', last='C'
s.observe('C'); // 'unchanged'(预置基线命中)
// 上面没有 'new' 路径,构造一个会判 'new' 的场景:
s.seed('基线');
s.markOwnWrite('X'); // pending='X', last='X'
s.observe('Y'); // pending 被消化, last='Y', 'new'
// 现在状态last='Y', pending=null。再来一次会判 'new' 的写入:
s.markOwnWrite('Z'); // pending='Z', last='Z'
assert.strictEqual(s.observe('Z'), 'unchanged');
// 模拟 pollClipboard 在 'new' 路径上失败 → 这里没有 new 路径,但语义上
// abortLastObserve 应同时把 last / pending 都清掉。下次 tick 读到任何
// 内容都重新判 'new'pendign 也不会错误地吞掉外部真实复制。
s.abortLastObserve();
assert.strictEqual(s.observe('anything'), 'new', 'abort 后 last 必须为空');
// 再 markOwnWrite 后能正常工作(说明 pending 也被清掉了,没残留)
s.markOwnWrite('P');
assert.strictEqual(s.observe('P'), 'unchanged', 'markOwnWrite 在 abort 后照常工作');
});
check('abortLastObserve无副作用last/pending 本来就 null 时调用不破坏)', () => {
const s = createEchoSuppress();
s.abortLastObserve(); // last=null, pending=null无副作用
assert.strictEqual(s.observe('A'), 'new');
s.abortLastObserve(); // last='A' → null下次再判 'new'
assert.strictEqual(s.observe('A'), 'new');
});
check('图片场景端到端:自己的图不入库、外部的图入库', () => {
const s = createEchoSuppress();
s.markOwnWrite('our-hash');
assert.strictEqual(s.observe('our-hash'), 'unchanged'); // 与预置基线一致,不入库
assert.strictEqual(s.observe('external-hash'), 'new'); // 外部换了图
assert.strictEqual(s.observe('external-hash'), 'unchanged');
});
check('文本与图片两份状态互不串扰main.js 用两个独立实例)', () => {
const text = createEchoSuppress();
const image = createEchoSuppress();
text.markOwnWrite('A');
assert.strictEqual(image.observe('A'), 'new', '图片状态机不该认识文本的 pending');
assert.strictEqual(text.observe('A'), 'unchanged', '文本状态机命中自己的预置基线');
});
console.log(`\n${passed} checks passed`);

84
test/echo-wiring.test.js Normal file
View File

@@ -0,0 +1,84 @@
'use strict';
/**
* 回声抑制的**接线**断言main.js 必须真的在用 lib/echo-suppress.js 的本体。
*
* 为什么需要这份测试echo-suppress.test.js 只测 lib 本体。本体的行为全对,
* 也挡不住 main.js 把接线改坏 —— 比如漏调 seed每次重启把剪贴板里的旧内容
* 重入一次)、漏调 markOwnWrite自己的复制被当新内容重复入库、observe 的
* 返回值不再按四态分发('empty' 当 'new' 入库 = 空剪贴板噪音灌满列表)。
* lib 测试对这类回归永远是绿的 —— 正是这个文件头当年批评旧测试的那句话。
*
* 风格沿用 window-factory.test.js / window-lifecycle.test.js 的静态断言:
* grep main.js 源码里的调用点。静态断言测不了运行时,但能拦住"重构时把
* 调用点删了/改名了"这类最常见的接线回归,成本几乎为零。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const MAIN = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('main.js 引用的是 lib/echo-suppress 本体', () => {
assert.match(MAIN, /require\('\.\/lib\/echo-suppress'\)/);
const { createEchoSuppress } = require('../lib/echo-suppress.js');
assert.strictEqual(typeof createEchoSuppress, 'function');
});
check('文本 / 图片各一个独立状态机实例(两份状态不能共用)', () => {
assert.match(MAIN, /textSuppress\s*=\s*createEchoSuppress\(\)/);
assert.match(MAIN, /imageSuppress\s*=\s*createEchoSuppress\(\)/);
const instances = MAIN.match(/createEchoSuppress\(\)/g) || [];
assert.strictEqual(instances.length, 2, `应有且仅有 2 个实例,实际 ${instances.length}`);
});
check('启动采样两条线都调 seed漏掉会导致重启时旧内容重入库', () => {
assert.match(MAIN, /textSuppress\.seed\(/);
assert.match(MAIN, /imageSuppress\.seed\(/);
});
check('轮询两条线都调 observe漏掉 text 的 = 搜索不到新复制,漏掉 image 的 = 图片全丢', () => {
assert.match(MAIN, /textSuppress\.observe\(/);
assert.match(MAIN, /imageSuppress\.observe\(/);
});
check('observe 的四态都被分发empty / suppressed / new 各自有分支', () => {
// unchanged 不需要显式分支(落空即图片分支),但其余三态必须显式处理。
// 漏 'empty':清空剪贴板的 '' 会被当新文本入库;漏 'suppressed':自己的回声重复入库。
for (const verdict of ['empty', 'suppressed', 'new']) {
assert.ok(
MAIN.includes(`verdict === '${verdict}'`),
`pollClipboard 缺少对 '${verdict}' 的分发`
);
}
});
check('写剪贴板两条线都调 markOwnWrite漏掉 = 点"复制"后自己的回声被当新内容入库', () => {
assert.match(MAIN, /textSuppress\.markOwnWrite\(/);
assert.match(MAIN, /imageSuppress\.markOwnWrite\(/);
});
check('1×1 空图走 resetBaseline 而不是直接赋基线', () => {
assert.match(MAIN, /imageSuppress\.resetBaseline\(\)/);
});
check('入库失败两条线都 abortLastObserve漏掉 = 用户那条复制永久吞掉直到重启', () => {
// 用户报告"卡住"的核心修法observe 把 last 设成新内容后入库失败,
// 必须回滚基线,否则下次 tick 读到同一条内容判 'unchanged' 静默吞掉。
assert.match(MAIN, /textSuppress\.abortLastObserve\(\)/);
assert.match(MAIN, /imageSuppress\.abortLastObserve\(\)/);
});
check('复制失败的两条 catch 都 clearPending漏掉会吞掉用户下一次真实复制', () => {
const clears = MAIN.match(/textSuppress\.clearPending\(\)/g) || [];
assert.ok(clears.length >= 2, `textSuppress.clearPending 应至少 2 处clip / fav 复制),实际 ${clears.length}`);
const imgClears = MAIN.match(/imageSuppress\.clearPending\(\)/g) || [];
assert.ok(imgClears.length >= 2, `imageSuppress.clearPending 应至少 2 处,实际 ${imgClears.length}`);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,221 @@
'use strict';
/**
* 「收藏 Tab 复制」也要把内容带到历史 Tab —— 行为与接线回归。
*
* 起因2026-08-09 用户反馈):用户点收藏卡的「复制」后,历史 Tab
* 最前面没冒出新条目。原因copyFavToClipboard 只调 writePayloadToClipboard
* 写完系统剪贴板就 return。下一轮询 tick 读到同样内容,被回声抑制当成
* 「自己写的」吞掉,历史表里完全没新行。
*
* 修法:写完剪贴板后调用 bringFavoriteIntoHistory(row),源记录还在就置顶
* (与历史 Tab 复制行为对齐),不在 / 无源就当作新条目入库。
*
* main.js 不能脱离 Electron 加载,仿 clip-limit.test.js 两层守:接线 +
* 在真 SQLite 上跑提取出来的生产 SQL。
*/
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 mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
// ---------- 静态favorites:copy 处理器调了 bridge ----------
check('favorites:copy IPC 处理器最终调到 bringFavoriteIntoHistory穿透 copyFavToClipboard', () => {
// handler 自己只调 copyFavToClipboard —— 函数体里直接 grep 不到 bridge。
// 但 handler → copyFavToClipboard → bridge 这条链断了就会让历史 Tab
// 安静地不冒条目所以两端都要验证handler 必须经由 copyFavToClipboard
// 间接桥接下面那条「copyFavToClipboard → bridge」已钉死
const handler = mainSrc.match(/ipcMain\.handle\(\s*'favorites:copy'[\s\S]*?\n\s{2}\}\s*\)\s*;/);
assert.ok(handler, '找不到 favorites:copy IPC 处理器');
assert.match(handler[0], /copyFavToClipboard\s*\(/,
'favorites:copy 处理器没走 copyFavToClipboard —— 桥接链断点');
});
check('copyFavToClipboard 在剪贴板写完后调 bridge且失败被吞', () => {
const fn = mainSrc.match(/function\s+copyFavToClipboard\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 copyFavToClipboard');
// 顺序:先 writePayloadToClipboard再 bridgebridge 必须放在 try/catch 里
assert.match(fn[0], /writePayloadToClipboard[\s\S]*?bringFavoriteIntoHistory/,
'bridge 调用必须在写完剪贴板之后');
// bridge 失败不能把整个复制操作搞砸(剪贴板已经写好了)
assert.match(fn[0], /try\s*\{[\s\S]*?bringFavoriteIntoHistory[\s\S]*?\}\s*catch/,
'bridge 调用必须放在 try/catch —— DB 写失败不能让用户看到「复制失败」');
});
// ---------- 静态bridge 函数两条分支都在 ----------
check('bringFavoriteIntoHistory源在历史里 → 置顶 + notifyRefresh', () => {
const fn = mainSrc.match(/function\s+bringFavoriteIntoHistory\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 bringFavoriteIntoHistory');
assert.match(fn[0], /stmt\.moveToTop\.run\(/,
'bridge 缺置顶分支 —— 源记录还在历史时不会置顶');
assert.match(fn[0], /notifyRefresh\s*\(\s*\)/,
'置顶分支没调 notifyRefresh —— 渲染端拿不到位置变更');
});
check('bringFavoriteIntoHistory源不在 / 无源 → insertText/insertImage + notifyNew', () => {
const fn = mainSrc.match(/function\s+bringFavoriteIntoHistory\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 bringFavoriteIntoHistory');
assert.match(fn[0], /stmt\.insertText\.run\(/, 'bridge 缺文本入库分支');
assert.match(fn[0], /stmt\.insertImage\.run\(/, 'bridge 缺图片入库分支');
// notifyNew 是新条目通知 —— 渲染端靠这个知道要 prepend
assert.match(fn[0], /notifyNew\s*\(/, 'bridge 缺 notifyNew —— 新条目不会冒头');
// 入库后要刷上限 + 计数,跟 pollClipboard 对齐
assert.match(fn[0], /enforceLimit\s*\(\s*\)/, 'bridge 缺 enforceLimit —— 限额失效');
assert.match(fn[0], /clipCount\s*\+\+/, 'bridge 没维护 clipCount 计数');
});
check('bridge 入库 top_at 必须是 null不能绑 now', () => {
// 跟 clip-limit.test.js 同坑insertText/insertImage 第 3 个实参必须是 null
// —— 绑 now 会让每条新记录「被置顶」,限额清理全废。
const fn = mainSrc.match(/function\s+bringFavoriteIntoHistory\s*\([\s\S]*?\n\}/);
assert.ok(fn);
// 行级断言:找两行 .run(...),看它们的第 3 个实参是不是 null
const textRun = fn[0].match(/insertText\.run\([^)]*\)/);
assert.ok(textRun, '找不到 insertText.run 调用');
const args = textRun[0].slice(textRun[0].indexOf('(') + 1, -1).split(',').map(s => s.trim());
assert.strictEqual(args.length, 4, 'insertText 应是 4 个实参');
assert.strictEqual(args[2], 'null', 'bridge.insertText 第 3 个实参top_at必须是 null');
const imageRun = fn[0].match(/insertImage\.run\([^)]*\)/);
assert.ok(imageRun, '找不到 insertImage.run 调用');
const iargs = imageRun[0].slice(imageRun[0].indexOf('(') + 1, -1).split(',').map(s => s.trim());
assert.strictEqual(iargs.length, 4, 'insertImage 应是 4 个实参');
assert.strictEqual(iargs[2], 'null', 'bridge.insertImage 第 3 个实参top_at必须是 null');
});
// ---------- 提取生产 SQL 跑行为验证 ----------
function extractSql(anchor) {
const re = new RegExp(`stmt\\.${anchor}\\s*=\\s*db\\.prepare\\(\\s*\`([\\s\\S]*?)\`\\s*\\)`);
const m = mainSrc.match(re);
assert.ok(m, `main.js 里找不到 stmt.${anchor} 的 prepare`);
return m[1];
}
const INSERT_TEXT_SQL = extractSql('insertText');
const INSERT_IMAGE_SQL = extractSql('insertImage');
const MOVE_TO_TOP_SQL = extractSql('moveToTop');
const GET_SQL = extractSql('get');
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fav-copy-bridge-'));
const db = new Database(path.join(tmp, 'history.db'));
db.exec(`
CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text TEXT,
image BLOB,
preview TEXT,
top_at REAL,
created_at REAL NOT NULL
)
`);
return db;
}
// 模拟生产环境bind = (text, preview, top_at=null, created_at)
const insertText = (db) => db.prepare(INSERT_TEXT_SQL);
const insertImage = (db) => db.prepare(INSERT_IMAGE_SQL);
const moveToTop = (db) => db.prepare(MOVE_TO_TOP_SQL);
const getRow = (db) => db.prepare(GET_SQL);
check('桥接 SQL 的列顺序确实兼容 main.js 入参 (text, preview, top_at, created_at)', () => {
// 静态断言成立的前提 —— 占位符顺序变了而调用没跟着变null 会落到别的列
assert.match(INSERT_TEXT_SQL, /VALUES\(\s*'text'\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*\)/,
'insertText 占位符顺序必须与桥接调用一致 —— (text, preview, top_at, created_at)');
assert.match(INSERT_IMAGE_SQL, /VALUES\(\s*'image'\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*\)/);
});
check('行为:源记录在历史里 → 置顶top_at 与 created_at 同步更新到 now', () => {
const db = makeDb();
// 历史里先有一条 id=1 的源记录top_at 原本为 null自然位置
const ins = insertText(db);
const r = ins.run('源记录内容', '源记录内容', null, 100);
const id = r.lastInsertRowid;
// 模拟 bridge 的「源在」分支movetop 把 top_at 与 created_at 同时置到 now
// —— 这样该行在列表里就是「最近活跃」,排到顶。
const now = 1000.5;
moveToTop(db).run(now, now, id);
const row = getRow(db).get(id);
assert.strictEqual(row.top_at, now, '置顶后 top_at 必须是当前时间戳');
assert.strictEqual(row.text, '源记录内容', '置顶不能改文本内容');
assert.strictEqual(row.created_at, now, '置顶必须同步 bump created_at否则列表里看不到这次活跃');
db.close();
});
check('行为:源记录不在历史里 → 当作新条目入库', () => {
const db = makeDb();
// 历史为空,源记录 id=999 不在表里
const ins = insertText(db);
const now = 2000;
// 模拟 bridge 的「新条目」分支
const info = ins.run('收藏里的文本', '收藏里的文本', null, now);
const id = info.lastInsertRowid;
// 验证:库里多了一行,类型/文本/preview 都对top_at=null自然位置
const all = db.prepare('SELECT * FROM clips').all();
assert.strictEqual(all.length, 1, '应该多了一条历史');
assert.strictEqual(all[0].id, id);
assert.strictEqual(all[0].type, 'text');
assert.strictEqual(all[0].text, '收藏里的文本');
assert.strictEqual(all[0].preview, '收藏里的文本');
assert.strictEqual(all[0].top_at, null, '新入库的记录必须在自然位置 —— top_at=null');
assert.strictEqual(all[0].created_at, now);
db.close();
});
check('行为:图片分支 —— 收藏复制 → 入库 BLOB + preview', () => {
const db = makeDb();
const now = 3000;
const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // PNG 头几字节
const preview = '[图片]';
// 模拟 bridge 的图片分支(不走 capImage 真实逻辑 —— 那是 main.js 的职责,
// 这里只验证 insertImage 本身的入参约定)
const info = insertImage(db).run(buf, preview, null, now);
const id = info.lastInsertRowid;
const row = db.prepare('SELECT type, image, preview, top_at, created_at FROM clips WHERE id = ?').get(id);
assert.strictEqual(row.type, 'image');
assert.deepStrictEqual(Array.from(row.image), [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10],
'图片 BLOB 必须原样入库');
assert.strictEqual(row.preview, '[图片]');
assert.strictEqual(row.top_at, null, '图片入库也必须在自然位置');
assert.strictEqual(row.created_at, now);
db.close();
});
check('行为:重复点收藏复制(源在历史里)→ 不会冒出第二条重复行', () => {
// 用户场景:复制一次收藏,置顶;再复制一次收藏,不应该出现重复条目
const db = makeDb();
const ins = insertText(db);
const r = ins.run('hello', 'hello', null, 100);
const id = r.lastInsertRowid;
// 第一次复制:源在 → 置顶top_at 与 created_at 同时 bump
moveToTop(db).run(200, 200, id);
// 第二次复制:源仍在 → 仍然置顶
moveToTop(db).run(300, 300, id);
const rows = db.prepare('SELECT * FROM clips').all();
assert.strictEqual(rows.length, 1, '重复置顶不能插入新行');
assert.strictEqual(rows[0].top_at, 300, 'top_at 取最新一次的时间戳');
assert.strictEqual(rows[0].created_at, 300, 'created_at 也取最新一次的时间戳');
db.close();
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,70 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const rendererSrc = fs.readFileSync(
path.join(__dirname, '..', 'renderer', 'renderer.js'), 'utf8'
);
const preloadSrc = fs.readFileSync(
path.join(__dirname, '..', 'preload.js'), 'utf8'
);
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; }
}
check('renderer 调用 favList 不传硬编码 limit', () => {
// displayLimit 改由配置驱动(默认 300renderer 不再硬编码 200。
// 主进程 clips:list / favorites:list 在 limit=0 时回退到 appConfig.displayLimit。
const reload = rendererSrc.match(/async\s+function\s+reload\s*\(\s*\)\s*\{[\s\S]*?\n\}/);
assert.ok(reload, '找不到 reload()');
const call = reload[0].match(/favList\s*\([^)]*\)/);
assert.ok(call, 'reload() 找不到 favList 调用');
assert.ok(
!/,\s*200\s*\)/.test(call[0]) && !/,\s*5000\s*\)/.test(call[0]),
`favList 不应再传硬编码 200/5000实际是: ${call[0]}`
);
});
check('renderer 收藏 Tab 的徽标不用 clips.length', () => {
// 切到 favorites 时,$favCount.textContent 不能来自 clips.length
// (被 200 截断后会失真)。
const favText = rendererSrc.match(/\$favCount\.textContent\s*=\s*[^;]+;/g) || [];
for (const line of favText) {
assert.ok(
!/clips\.length/.test(line),
`徽标计数值不能从 clips.length 取(限制 200 后会失真): ${line}`
);
}
});
check('setTab 切到 favorites 时先 updateFavCount 再 reload', () => {
const setTab = rendererSrc.match(/function\s+setTab\s*\(\s*\w+\s*\)\s*\{[\s\S]*?\n\}/);
assert.ok(setTab, '找不到 setTab');
// 切到 favorites 的分支里,两个调用都得出现;顺序 updateFavCount 必须先于 reload
const idxFav = setTab[0].indexOf("'favorites'");
assert.ok(idxFav >= 0, 'setTab 找不到 favorites 分支');
// 截 favorites 分支之后到函数末尾
const favBranch = setTab[0].slice(idxFav);
const idxCount = favBranch.indexOf('updateFavCount');
const idxReload = favBranch.indexOf('reload');
assert.ok(idxCount >= 0, '切到 favorites 时没调 updateFavCount()');
assert.ok(idxReload >= 0, '切到 favorites 时没调 reload()');
assert.ok(idxCount < idxReload, '必须先 updateFavCount 再 reload');
});
check('preload favList 把第二个参数 limit 透传给 IPC', () => {
// preload 的 favoritesApi.list 必须接收并转发 limit。
// 之前传 5000现在传 0主进程在 limit <= 0 时回退到 appConfig.displayLimit。
const call = preloadSrc.match(/list:\s*\(query,\s*limit\)\s*=>\s*ipcRenderer\.invoke\(\s*'favorites:list'[^)]*\)/);
assert.ok(call, 'preload favorites list 没有 (query, limit) 签名或没转发 limit');
assert.ok(
/limit:\s*limit\s*\|\|\s*0/.test(call[0]),
`preload favList 必须把 limit 透传limit: limit || 0实际: ${call[0]}`
);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,21 @@
'use strict';
const assert = require('node:assert');
const { checkAgainstCap, DEFAULT_MAX } = require('../lib/favorites-cap.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; }
}
const mockDb = (n) => ({ prepare: () => ({ get: () => ({ n }) }) });
check('under cap returns ok', () => {
assert.deepStrictEqual(checkAgainstCap(mockDb(0)), { ok: true });
});
check('at cap returns { ok: false, message }', () => {
const r = checkAgainstCap(mockDb(DEFAULT_MAX));
assert.strictEqual(r.ok, false);
assert.ok(r.message.includes(String(DEFAULT_MAX)));
});

247
test/favorites.test.js Normal file
View File

@@ -0,0 +1,247 @@
'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`);
})();

40
test/fts-search.test.js Normal file
View File

@@ -0,0 +1,40 @@
'use strict';
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 tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-fts-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT)`);
let hasFts5 = true;
try { db.exec(`CREATE VIRTUAL TABLE clips_fts USING fts5(text, content='clips', content_rowid='id')`); }
catch (e) {
hasFts5 = false;
db.close();
console.log(' SKIP - no FTS5 available');
process.exit(0);
}
db.exec(`
CREATE TRIGGER clips_ai AFTER INSERT ON clips BEGIN INSERT INTO clips_fts(rowid,text) VALUES (new.id, new.text); END;
`);
for (let i = 0; i < 50; i++) {
const txt = i % 3 === 0 ? `hello world ${i}` : `lorem ipsum ${i}`;
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', txt);
}
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; }
}
check('FTS5 MATCH finds exact substring', () => {
const r = db.prepare(`SELECT rowid FROM clips_fts WHERE clips_fts MATCH ?`).all('hello');
assert.ok(r.length > 0);
});
db.close();

23
test/image-cap.test.js Normal file
View File

@@ -0,0 +1,23 @@
'use strict';
const assert = require('node:assert');
const { capImage } = require('../lib/image-cap');
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; }
}
check('small buffer returned untouched', () => {
const buf = Buffer.alloc(1024); // 1 KB
const r = capImage(buf);
assert.strictEqual(r.buf, buf, 'small images should not be modified');
assert.strictEqual(r.preview, null);
});
check('oversized buffer triggers preview placeholder', () => {
const buf = Buffer.alloc(3 * 1024 * 1024); // 3 MB
const r = capImage(buf);
assert.ok(r.preview !== null, 'oversized images should produce preview');
assert.strictEqual(r.preview, '[图片]');
});

248
test/ipc-wiring.test.js Normal file
View File

@@ -0,0 +1,248 @@
'use strict';
/**
* 接线测试:把"存在但没接上"这一类 bug 挡住。
*
* 这次审查里同时出现了三个同类问题:
* 1. notify-fanout 有 addTheme(),但没有任何 theme:subscribe 处理器往里加订阅者,
* 于是 broadcastTheme() 永远在往空集合发消息 —— "跟随系统"静默失效。
* 2. package.json 的 build.files 漏了 lib/,打出来的包一启动就 MODULE_NOT_FOUND。
* 3. preload 暴露了 listLite但没有任何调用方。
*
* 这些都不是逻辑错误,靠读单个文件看不出来,只有跨文件对照才能发现。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const mainSrc = read('main.js');
const preloadSrc = read('preload.js');
const pkg = JSON.parse(read('package.json'));
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; }
}
// ---------- IPC 通道两端必须对齐 ----------
const handled = new Set(
[...mainSrc.matchAll(/ipcMain\.handle\(\s*'([^']+)'/g)].map((m) => m[1])
);
const invoked = new Set(
[...preloadSrc.matchAll(/ipcRenderer\.invoke\(\s*'([^']+)'/g)].map((m) => m[1])
);
check('preload 调用的每个通道,主进程都有处理器', () => {
const missing = [...invoked].filter((c) => !handled.has(c));
assert.deepStrictEqual(missing, [], `preload 调用了未注册的通道: ${missing.join(', ')}`);
});
check('主进程注册的每个通道,都有 preload 出口', () => {
const unreachable = [...handled].filter((c) => !invoked.has(c));
assert.deepStrictEqual(unreachable, [], `注册了但渲染进程够不着: ${unreachable.join(', ')}`);
});
// ---------- 推送通道:发送方与订阅方必须成对 ----------
const sentChannels = new Set(
[...mainSrc.matchAll(/notify(?:Clip|Fav|Theme)\(\s*'([^']+)'/g)].map((m) => m[1])
);
const listened = new Set(
[...preloadSrc.matchAll(/ipcRenderer\.on\(\s*'([^']+)'/g)].map((m) => m[1])
);
check('主进程广播的每个事件preload 都在监听', () => {
const deaf = [...sentChannels].filter((c) => !listened.has(c));
assert.deepStrictEqual(deaf, [], `广播了但没人听: ${deaf.join(', ')}`);
});
check('每个 fanout 集合都有对应的 subscribe 处理器', () => {
// addClip / addFav / addTheme 各自都必须被某个 ipcMain.handle 调用到
for (const adder of ['addClip', 'addFav', 'addTheme']) {
assert.ok(
new RegExp(`subscribers\\.${adder}\\(`).test(mainSrc),
`subscribers.${adder}() 从未被调用 —— 对应的广播会发进空集合`
);
}
});
check('theme:subscribe 存在(跟随系统主题依赖它)', () => {
assert.ok(handled.has('theme:subscribe'), '主进程缺少 theme:subscribe');
assert.ok(invoked.has('theme:subscribe'), 'preload 未暴露 theme:subscribe');
});
// ---------- 打包清单必须覆盖所有本地 require ----------
check('build.files 覆盖了 main/favorites 本地 require 的每个目录', () => {
const files = pkg.build.files;
const needed = new Set();
for (const src of [mainSrc, read('favorites.js')]) {
for (const m of src.matchAll(/require\(\s*'\.\/([^']+)'/g)) {
const spec = m[1];
if (spec.includes('/')) {
needed.add(spec.split('/')[0] + '/'); // 目录,如 lib/
} else {
needed.add(spec.endsWith('.js') ? spec : spec + '.js'); // 单文件,如 config -> config.js
}
}
}
for (const entry of needed) {
const covered = entry.endsWith('/')
? files.some((f) => f === entry.slice(0, -1) || f.startsWith(entry))
: files.includes(entry);
assert.ok(covered, `${entry} 被 require 但不在 build.files 里 —— 打包后会 MODULE_NOT_FOUND`);
}
});
check('lib/ 显式在打包清单里', () => {
assert.ok(
pkg.build.files.some((f) => f === 'lib' || f.startsWith('lib/')),
'build.files 缺少 lib/'
);
});
// ---------- 依赖不能是死重量 ----------
check('每个 dependency 都真的被 require 了', () => {
const srcFiles = ['main.js', 'preload.js', 'favorites.js', 'config.js', 'tray-menu.js']
.map(read)
.concat(fs.readdirSync(path.join(root, 'lib')).map((f) => read(path.join('lib', f))))
.join('\n');
const unused = Object.keys(pkg.dependencies).filter(
(dep) => !new RegExp(`require\\(\\s*'${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`).test(srcFiles)
);
assert.deepStrictEqual(unused, [], `声明了但没用到的依赖: ${unused.join(', ')}`);
});
// ---------- 渲染进程只能用 preload 真的暴露了的东西 ----------
check('renderer 从 window.* 解构的字段都存在于 preload主窗口 + 设置窗口)', () => {
const exposed = {};
for (const m of preloadSrc.matchAll(/exposeInMainWorld\('(\w+)',\s*\{([\s\S]*?)\n\}\)/g)) {
exposed[m[1]] = new Set([...m[2].matchAll(/^\s{2}(\w+):/gm)].map((x) => x[1]));
}
// 两个渲染窗口都要查settings.js 的 openExternal 解构曾经漏在这张网外面
for (const file of ['renderer/renderer.js', 'renderer/settings.js']) {
const rendererSrc = read(file);
for (const m of rendererSrc.matchAll(/const\s*\{([\s\S]*?)\}\s*=\s*window\.(\w+);/g)) {
const bridge = m[2];
if (!exposed[bridge]) continue;
for (const entry of m[1].split(',')) {
const name = entry.split(':')[0].trim();
if (!name || name.startsWith('//')) continue;
assert.ok(
exposed[bridge].has(name),
`${file} 解构了 window.${bridge}.${name},但 preload 没暴露它`
);
}
}
}
});
// ---------- 自动粘贴已移除2026-07-30界面任何时候都不隐藏 ----------
// 曾用 keyhelper 发 Ctrl+V需要先隐藏窗口交出焦点会有"缩一下"的动画,
// 用户明确不要。现在只写系统剪贴板,用户自己切窗口 Ctrl+V。
check('main.js 不再 require keyhelper自动粘贴已移除', () => {
assert.ok(
!/require\(\s*'\.\/keyhelper'\s*\)/.test(mainSrc),
'main.js 仍在 require keyhelper —— 自动粘贴应已整体移除'
);
});
check('clips:movetop 处理器里不再调用 keyHelper.sendCtrlV', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'clips:movetop'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 clips:movetop 处理器');
assert.ok(
!/sendCtrlV/.test(handler[0]),
'clips:movetop 仍在调 sendCtrlV —— 自动粘贴应已移除'
);
});
check('clips:movetop 处理器里不再隐藏/最小化窗口', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'clips:movetop'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 clips:movetop 处理器');
assert.ok(
!/mainWindow\s*\.\s*(hide|minimize)\s*\(/.test(handler[0]),
'clips:movetop 仍在 hide()/minimize() 窗口 —— 界面应始终保持可见'
);
});
// ---------- 两个 id 空间 / 两份缓存:容易"复用错"的地方 ----------
check('settings:clear-history 作废行数缓存并通知窗口刷新', () => {
// 不回源 clipCount设了 maxItems 时下次入库会按过期行数误删新记录;
// 不 notifyRefresh开着的主窗口停在已删除的项上。
const handler = mainSrc.match(/ipcMain\.handle\(\s*'settings:clear-history'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 settings:clear-history 处理器');
assert.ok(
/clipCount\s*=\s*null/.test(handler[0]),
'settings:clear-history 没作废 clipCount —— enforceLimit 会按过期行数误删'
);
assert.ok(
/notifyRefresh\(\)/.test(handler[0]),
'settings:clear-history 没通知窗口刷新'
);
});
check('favorites:copy 两端接线(收藏复制不能复用 clips 的 id 空间)', () => {
// favId 与历史 id 分属两个库、自增数会撞号 —— 收藏 Tab 的复制
// 必须有自己从收藏行取内容的通道,不能走 clips:movetop。
assert.ok(handled.has('favorites:copy'), '主进程缺少 favorites:copy 处理器');
assert.ok(invoked.has('favorites:copy'), 'preload 未暴露 favorites:copy');
});
// ---------- settings:choose-dir动数据库的处理器坏一次就丢数据 ----------
// choose-dir 是 registerIpc 里最后一个 handler从它起捕获到 registerIpc 结尾
// (列 0 的 `}`handler 内部的闭合括号都有缩进,不会被误截)。
// 仓库文件是 CRLF 行尾,锚点要容忍 \r其余检查靠 \s* 天然兼容)。
const chooseDir = mainSrc.match(/ipcMain\.handle\(\s*'settings:choose-dir'[\s\S]*?\r?\n\}\r?\n/);
check('settings:choose-dir 存在', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
});
check('settings:choose-dir 用 isSameDir 判"已是当前目录"Windows 大小写不敏感)', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
// 比 effectiveDataDir()(含 session 兜底),不是裸 appConfig.dataDir
// 启动时盘符不可用会切到 session 兜底目录,此时用户重选这个目录就不
// 该弹迁移对话框自己迁自己。
assert.ok(
/isSameDir\(\s*newDir\s*,\s*effectiveDataDir\(\s*\)\s*\)/.test(chooseDir[0]),
'settings:choose-dir 未走 isSameDir —— Windows 上大小写不一致的重选会绕过' +
'"已经是当前数据位置"提示,弹出迁移对话框自己迁自己'
);
});
check('settings:choose-dir 先停轮询再关数据库', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
const stopAt = chooseDir[0].indexOf('stopPolling()');
// db.close() 调用名可能因局部变量d.close / favDb.close 等)写得不完全一致,
// 任何形如 "<ident>.close()" 且 stopPolling 在它之前就足以说明顺序正确。
const closeAt = chooseDir[0].search(/[a-zA-Z_$][\w$]*\.close\(\)/);
assert.ok(stopAt >= 0, 'settings:choose-dir 没有 stopPolling —— 迁移期间 tick 会打中已关闭的连接');
assert.ok(closeAt >= 0 && stopAt < closeAt, 'stopPolling() 必须在关闭数据库之前');
});
// ---------- npm scripts终端环境差异不能让应用起不来 ----------
check('start / test:ui 脚本隔离 ELECTRON_RUN_AS_NODE', () => {
// VS Code及任何 Electron 派生)终端会注入 ELECTRON_RUN_AS_NODE=1
// 不加防护时 `electron .` 退化成纯 Node 跑 main.jsrequire('electron')
// 解构即抛 TypeError —— npm start / npm run test:ui 在这些终端里必挂。
// 注意必须带引号:`set X= && ...` 会把值设成一个空格(尾随空格进了值),
// 变量仍在、Electron 照样退化;`set "X=" && ...` 才是真的删除。
// npm scripts 在 Windows 上一律经 cmd.exe与调用方终端无关。
for (const name of ['start', 'test:ui']) {
assert.ok(
/set\s+"ELECTRON_RUN_AS_NODE="\s*&&/.test(pkg.scripts[name] || ''),
`scripts.${name} 未正确清掉 ELECTRON_RUN_AS_NODE —— VS Code 终端里会崩`
);
}
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,67 @@
'use strict';
const assert = require('node:assert');
const events = require('node:events');
let spawnedCount = 0;
class MockChild extends events.EventEmitter {
constructor() {
super();
this.stdin = { write: (s) => { this._lastWrite = s; }, end: () => {} };
this.stdout = new events.EventEmitter();
this.stderr = new events.EventEmitter();
spawnedCount++;
}
}
const cp = require('node:child_process');
const origSpawn = cp.spawn;
cp.spawn = () => new MockChild();
const KeyHelper = require('../keyhelper.js');
let passed = 0;
function check(name, fn) {
return Promise.resolve()
.then(fn)
.then(() => { console.log(' ok -', name); passed++; })
.catch((e) => { console.error(' FAIL -', name, e.message); process.exitCode = 1; });
}
(async () => {
await check('spawn is invoked when KeyHelper is constructed', () => {
const kh = new KeyHelper();
assert.strictEqual(spawnedCount >= 1, true);
kh.dispose();
});
await check('sendCtrlV returns a promise (no immediate throw)', () => {
const kh = new KeyHelper();
const p = kh.sendCtrlV();
assert.ok(p instanceof Promise);
kh.dispose();
// dispose() 必须把挂起的请求结掉。否则 10 秒后的超时会变成一个
// 没人接的 rejection并且那个 timer 会一直吊着事件循环。
return assert.rejects(p, /disposed/);
});
await check('dispose() clears pending map and does not leave timers', () => {
const kh = new KeyHelper();
const p = kh.sendCtrlV();
assert.strictEqual(kh.pending.size, 1);
kh.dispose();
assert.strictEqual(kh.pending.size, 0);
return p.catch(() => {});
});
await check('dispose() stops the reconnect loop', () => {
const kh = new KeyHelper();
const before = spawnedCount;
kh.dispose();
kh.ps.emit('exit', 1); // 已 dispose不应再重连
return new Promise((r) => setTimeout(r, 700))
.then(() => assert.strictEqual(spawnedCount, before));
});
cp.spawn = origSpawn;
console.log(`\n${passed} checks passed`);
})();

119
test/legacy-migrate.test.js Normal file
View File

@@ -0,0 +1,119 @@
'use strict';
/**
* 老数据目录(%USERPROFILE%\.clipboard-app→ 新默认目录(项目/exe 同级 data/
* 的一次性迁移。
*
* 三条底线:
* 1. 迁完 config.json 里的 dataDir 必须指向新目录 —— 否则下次启动又跑回老地方;
* 2. 新目录已经在用时不得覆盖(幂等,重复启动不会拿老库盖新库);
* 3. 老文件保留 —— 复制不是移动,出问题能回退。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-mig-'));
const home = path.join(root, 'home');
const target = path.join(root, 'Clipboard Data'); // 模拟 D:\Clipboard Data
const legacy = path.join(home, '.clipboard-app');
process.env.USERPROFILE = home;
process.env.HOME = home;
process.env.CLIPBOARD_APP_DATA_DIR = target;
const cfg = require('../config.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
function resetTarget() {
fs.rmSync(target, { recursive: true, force: true });
}
function seedLegacy(extraCfg) {
fs.rmSync(legacy, { recursive: true, force: true });
fs.mkdirSync(legacy, { recursive: true });
fs.writeFileSync(path.join(legacy, 'history.db'), 'OLD-HISTORY');
fs.writeFileSync(path.join(legacy, 'history.db-wal'), 'OLD-WAL');
fs.writeFileSync(path.join(legacy, 'favorites.db'), 'OLD-FAV');
fs.writeFileSync(path.join(legacy, 'history.db-shm'), 'OLD-SHM');
fs.writeFileSync(
path.join(legacy, 'config.json'),
JSON.stringify({ maxItems: 500, pollMs: 500, theme: 'carbon', dataDir: legacy, ...extraCfg }),
);
}
check('老数据被复制到新目录,且 dataDir 改写为新目录', () => {
resetTarget();
seedLegacy();
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, true);
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'OLD-HISTORY');
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db-wal'), 'utf8'), 'OLD-WAL');
assert.strictEqual(fs.readFileSync(path.join(target, 'favorites.db'), 'utf8'), 'OLD-FAV');
const c = cfg.loadConfig();
assert.strictEqual(path.resolve(c.dataDir), path.resolve(target), 'dataDir 必须指向新目录');
assert.strictEqual(c.maxItems, 500, '其他设置要保留');
assert.strictEqual(c.theme, 'carbon');
});
check('-shm 不搬SQLite 自己重建)', () => {
assert.ok(!fs.existsSync(path.join(target, 'history.db-shm')));
});
check('老文件保留,不是移动', () => {
assert.ok(fs.existsSync(path.join(legacy, 'history.db')));
assert.ok(fs.existsSync(path.join(legacy, 'config.json')));
});
check('幂等:新目录已在用时不覆盖', () => {
fs.writeFileSync(path.join(target, 'history.db'), 'NEW-HISTORY');
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, false);
assert.strictEqual(r.reason, 'target-in-use');
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'NEW-HISTORY');
});
check('没有老数据时什么都不做', () => {
resetTarget();
fs.rmSync(legacy, { recursive: true, force: true });
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, false);
assert.strictEqual(r.reason, 'nothing-to-migrate');
assert.ok(!fs.existsSync(path.join(target, 'config.json')));
});
check('老 config 指向自定义目录时,从那个目录搬', () => {
resetTarget();
const custom = path.join(root, 'custom');
fs.mkdirSync(custom, { recursive: true });
fs.writeFileSync(path.join(custom, 'history.db'), 'CUSTOM-HISTORY');
seedLegacy({ dataDir: custom });
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, true);
assert.strictEqual(path.resolve(r.from), path.resolve(custom));
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'CUSTOM-HISTORY');
assert.strictEqual(path.resolve(cfg.loadConfig().dataDir), path.resolve(target));
});
check('老 config 缺 dataDir 字段时,从老目录搬(不能用 sanitize 回填的新默认目录当源)', () => {
// sanitize 会把缺省的 dataDir 补成新默认目录(= target拿它当 src
// 就是"从 target 搬到 target":什么也没搬却报 migrated老数据永久遗留。
resetTarget();
fs.rmSync(legacy, { recursive: true, force: true });
fs.mkdirSync(legacy, { recursive: true });
fs.writeFileSync(path.join(legacy, 'history.db'), 'OLD-HISTORY');
fs.writeFileSync(path.join(legacy, 'config.json'), JSON.stringify({ maxItems: 100, pollMs: 500 }));
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, true);
assert.strictEqual(path.resolve(r.from), path.resolve(legacy), 'src 必须是老目录');
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'OLD-HISTORY');
});
fs.rmSync(root, { recursive: true, force: true });
console.log(`\n${passed} checks passed`);

53
test/list-join.test.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict';
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 tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-join-'));
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
);
CREATE TABLE favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_clip_id INTEGER UNIQUE, favorited_at REAL NOT NULL
);
`);
const now = Date.now()/1000;
// created_at 必须互不相同,否则 ORDER BY created_at DESC 的次序未定义,断言就是碰运气
db.prepare('INSERT INTO clips(type,text,preview,top_at,created_at) VALUES(?,?,?,?,?)').run('text','a','a',null,now - 2);
db.prepare('INSERT INTO clips(type,text,preview,top_at,created_at) VALUES(?,?,?,?,?)').run('text','b','b',null,now - 1);
db.prepare('INSERT INTO clips(type,text,preview,top_at,created_at) VALUES(?,?,?,?,?)').run('text','c','c',null,now);
const favInfo = db.prepare('INSERT INTO favorites(source_clip_id, favorited_at) VALUES(?,?)').run(2, now);
const favIdOfClip2 = favInfo.lastInsertRowid; // = favorites.id不是 clip.id
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; }
}
check('LEFT JOIN returns one row per clip with fav_id when matched', () => {
const rows = db.prepare(`
SELECT c.id, c.text, f.id AS fav_id
FROM clips c
LEFT JOIN favorites f ON f.source_clip_id = c.id
ORDER BY c.created_at DESC
`).all();
assert.strictEqual(rows.length, 3, 'JOIN 不应放大行数');
// created_at DESC → c(3), b(2), a(1);被收藏的是 clip 2 = 中间那条
assert.deepStrictEqual(rows.map(r => r.id), [3, 2, 1]);
assert.strictEqual(rows[0].fav_id, null);
// fav_id 是 favorites 表的主键,不是 source_clip_id —— 这两个值以前被搞混了
assert.strictEqual(rows[1].fav_id, favIdOfClip2);
assert.notStrictEqual(rows[1].fav_id, rows[1].id, 'favId 与 clipId 是不同的命名空间');
assert.strictEqual(rows[2].fav_id, null);
});
db.close();

View File

@@ -0,0 +1,128 @@
'use strict';
/**
* 回归测试v1 -> v2 迁移
*
* 曾经的 bugmigrate() 用 STEPS[v - 1] 取步骤,但 STEPS[0] 就是 v1->v2 的步骤,
* 所以 v=2 时取到的是 STEPS[1] === undefined整个 FTS5 迁移从来没跑过。
* 表现clips_fts 永远不存在,搜索一直静默退回 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 { migrate, STEPS } = 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; }
}
function freshDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-mig2-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT,
top_at REAL, created_at REAL NOT NULL DEFAULT 0)`);
return db;
}
check('STEPS[0] is the v1->v2 step (index maps version-2, not version-1)', () => {
assert.strictEqual(typeof STEPS[0], 'function');
assert.match(STEPS[0].name, /v1_to_v2/);
});
// 探测这个 SQLite 构建是否带 FTS5没有的话下面的断言换成"优雅降级"
let fts5 = true;
{
const db = freshDb();
try { db.exec(`CREATE VIRTUAL TABLE t USING fts5(x)`); } catch (_) { fts5 = false; }
db.close();
}
check('migrate actually reaches v2 and reports it', () => {
const db = freshDb();
const reached = migrate(db, 1, 2);
if (fts5) {
assert.strictEqual(reached, 2, '迁移应到达 v2');
assert.strictEqual(db.pragma('user_version', { simple: true }), 2);
} else {
assert.strictEqual(reached, 1, '没有 FTS5 时应停在 v1而不是谎报成功');
}
db.close();
});
if (fts5) {
check('clips_fts exists and is searchable after migrate', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
const rows = db.prepare(`SELECT rowid FROM clips_fts WHERE clips_fts MATCH 'hello'`).all();
assert.strictEqual(rows.length, 1, '已有数据应被回填进 FTS 索引');
db.close();
});
check('migrate is idempotent across restarts (no duplicate index entries)', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
for (let launch = 0; launch < 3; launch++) migrate(db, 1, 2);
const n = db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c;
assert.strictEqual(n, 1, '重复启动不应重复回填');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('user_version short-circuits the backfill on subsequent runs', () => {
const db = freshDb();
migrate(db, 1, 2);
// 直接往 clips 插一行但绕开触发器是做不到的,所以改为验证:
// 第二次 migrate 不再执行回填(否则这行会被索引两次)
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'unique token zzz');
migrate(db, 1, 2);
const n = db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'zzz'`).get().c;
assert.strictEqual(n, 1);
db.close();
});
check('AFTER UPDATE trigger skips FTS churn when only top_at changes', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
db.prepare('UPDATE clips SET top_at = ? WHERE id = 1').run(123);
assert.strictEqual(
db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 1,
'置顶不应影响可搜索性');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('AFTER UPDATE trigger still re-indexes when text changes', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
db.prepare('UPDATE clips SET text = ? WHERE id = 1').run('goodbye moon');
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 0);
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'goodbye'`).get().c, 1);
db.close();
});
check('DELETE keeps the FTS index consistent', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
db.prepare('DELETE FROM clips WHERE id = 1').run();
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 0);
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
} else {
console.log(' SKIP - FTS5 unavailable in this SQLite build');
}
check('migrate on a broken db degrades instead of throwing', () => {
const bad = { pragma() { throw new Error('nope'); }, exec() { throw new Error('nope'); } };
assert.strictEqual(migrate(bad, 1, 2), 1);
});
console.log(`\n${passed} checks passed`);

28
test/notify-sets.test.js Normal file
View File

@@ -0,0 +1,28 @@
'use strict';
const assert = require('node:assert');
const { createSubscribers } = require('../lib/notify-fanout.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; }
}
const subs = createSubscribers();
const wcA = { id: 'A', send: function (ch, payload) { this._last = [ch, payload]; } };
const wcB = { id: 'B', send: function (ch, payload) { this._last = [ch, payload]; } };
subs.addClip(wcA);
subs.addFav(wcB);
check('notifyClip only sends to clip subs', () => {
subs.notifyClip('clips:added', { id: 1 });
assert.deepStrictEqual(wcA._last, ['clips:added', { id: 1 }]);
assert.strictEqual(wcB._last, undefined);
});
check('notifyFav only sends to fav subs', () => {
subs.notifyFav('favorites:changed');
assert.strictEqual(wcA._last[0], 'clips:added', 'A untouched');
assert.strictEqual(wcB._last[0], 'favorites:changed');
});

186
test/order-by.test.js Normal file
View File

@@ -0,0 +1,186 @@
'use strict';
/**
* 列表排序:验证 main.js 里 orderBy() 的实现。
*
* 排序契约:列表按「最近活跃」倒序,即 created_at DESC。
*
* - 外部新复制:插入时 created_at = now → 自然排到顶
* - 用户点过「复制」movetop 把 created_at 同步更新到 now → 同样排到顶
* - 其它项按 created_at DESC 自然排列
*
* 早期版本用过 `ORDER BY top_at DESC, created_at DESC`,但它让「外部新复制」
* 排在「会话内置顶项」之后 —— 即使新复制才是当前剪贴板内容,列表顶上
* 显示的还是上一会话里被点过复制的旧项。这违反「第一个就是当前剪贴板
* 内容」的契约。改成 created_at DESC 后,无论外部复制还是点过复制,
* 都按最近活跃时间排序,问题自然消失。
*
* 用 id DESC 作 tiebreaker同 ms 内多次变更给一个稳定次序。
*/
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 NEW = 'ORDER BY created_at DESC, id DESC';
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; }
}
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-ob-'));
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)`);
// 复合索引created_at DESC, id DESC —— 完整覆盖 ORDER BY 的两列,
// 不需要「TEMP B-TREE FOR LAST TERM OF ORDER BY」给 id DESC 兜底。
db.exec(`CREATE INDEX idx_clips_created_id ON clips(created_at DESC, id DESC)`);
return db;
}
const ids = (db, order, limit = 1000) =>
db.prepare(`SELECT id FROM clips ${order} LIMIT ?`).all(limit).map((r) => r.id);
check('外部新复制天然排到顶created_at 最大的在最前', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100); // id=1, created_at=100
ins.run('text', 'b', null, 300); // id=2, created_at=300
ins.run('text', 'c', 500, 200); // id=3, created_at=200之前被点过复制
ins.run('text', 'd', null, 200); // id=4, created_at=200
ins.run('text', 'e', 900, 100); // id=5, created_at=100之前被点过复制
ins.run('text', 'f', null, 400); // id=6, created_at=400
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// created_at DESCf(400), b(300), d(200), c(200), e(100), a(100)
// created_at 相同的 (d/c=200, e/a=100) 之间用 id DESC 决出次序:
// - 同为 200d(id=4) > c(id=3) → d 在前
// - 同为 100e(id=5) > a(id=1) → e 在前
assert.deepStrictEqual(got, ['f', 'b', 'd', 'c', 'e', 'a']);
db.close();
});
check('点过复制的项也排到顶movetop 把 created_at 同步更新到 now', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100);
ins.run('text', 'b', null, 200);
ins.run('text', 'c', null, 300);
// 模拟点过复制 c —— movetop 把 top_at 和 created_at 都更新到 now
db.prepare('UPDATE clips SET top_at = ?, created_at = ? WHERE id = (SELECT id FROM clips WHERE text = ?)').run(1000, 1000, 'a');
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// a(1000) > c(300) > b(200)
assert.deepStrictEqual(got, ['a', 'c', 'b']);
db.close();
});
check('外部新复制总会超过任何会话内置顶项(契约)', () => {
// 这个 case 就是用户报的 bug会话内点了 c 的复制 → c: top_at=500, created_at=200
// 接着又从外面复制了 f → f: top_at=NULL, created_at=400
// 老排序 (top_at DESC) 会把 c 排到 f 前面,错。
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'c', 500, 200);
ins.run('text', 'f', null, 400);
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
assert.deepStrictEqual(got, ['f', 'c'], '最新复制 (f) 必须在被点过复制的 (c) 前面');
db.close();
});
check('全部 created_at 相同时按 id DESC 给出稳定次序', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100);
ins.run('text', 'b', null, 100);
ins.run('text', 'c', null, 100);
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// 同一 created_atid DESC → c, b, a
assert.deepStrictEqual(got, ['c', 'b', 'a']);
db.close();
});
check('1 万行随机数据:按 created_at DESC + id DESC 排序稳定', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 10000; i++) {
ins.run('text', 't' + i, i % 7 === 0 ? (i * 13) % 5000 : null, (i * 31) % 9999);
}
})();
// 不论 top_at 是什么值ORDER BY created_at DESC, id DESC 永远稳定
const rows = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id);
// 跑两次结果必须完全一致id DESC tiebreaker 起作用)
const again = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id);
assert.deepStrictEqual(rows, again, 'id DESC 兜底让排序可重复');
// 行数对上
assert.strictEqual(rows.length, 500);
db.close();
});
check('新 ORDER BY 能走 idx_clips_created_id 复合索引(不再全表排序)', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 2000; i++) ins.run('text', 't' + i, null, i);
})();
db.exec('ANALYZE');
const plan = db
.prepare(`EXPLAIN QUERY PLAN SELECT id, top_at, created_at FROM clips ${NEW} LIMIT 200`)
.all().map((r) => r.detail).join(' | ');
// 视 SELECT 的列能否被索引完全覆盖SQLite 会报 "USING INDEX" 或
// "USING COVERING INDEX" —— 两者都算走上了索引。
assert.match(plan, /USING (COVERING )?INDEX idx_clips_created_id/, `应走 idx_clips_created_id实际: ${plan}`);
assert.doesNotMatch(plan, /TEMP B-TREE/, `不应再有临时排序,实际: ${plan}`);
db.close();
});
check('带完整列的真实列表查询同样不会退回全表排序', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,image,preview,top_at,created_at) VALUES(?,?,?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 2000; i++) {
ins.run('text', 't' + i, null, 'p' + i, i % 7 === 0 ? (i * 13) % 5000 : null, i);
}
})();
const cols = 'id, type, text, preview, top_at, created_at, (image IS NOT NULL) AS has_image';
const detail = db
.prepare(`EXPLAIN QUERY PLAN SELECT ${cols} FROM clips ${NEW} LIMIT 200`)
.all().map((r) => r.detail).join(' | ');
assert.match(detail, /idx_clips_created_id/, `应走 idx_clips_created_id实际: ${detail}`);
assert.doesNotMatch(detail, /TEMP B-TREE/, `不应临时排序,实际: ${detail}`);
db.close();
});
check('main.js 的 orderBy 返回 created_at DESC, id DESC不再用 top_at 排序)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const orderByMatch = src.match(/const\s+orderBy\s*=\s*\([^)]*\)\s*=>\s*`([^`]+)`/);
assert.ok(orderByMatch, '找不到 orderBy 定义');
const expr = orderByMatch[1];
assert.ok(
/created_at\s+DESC/.test(expr),
`orderBy 必须按 created_at DESC 排序,实际: ${expr}`
);
assert.ok(
!/top_at\s+DESC/.test(expr),
`orderBy 不应再用 top_at 排序,实际: ${expr}`
);
});
check('main.js 不再残留 (top_at IS NULL) 排序表达式', () => {
// 早期版本写过 `(top_at IS NULL), top_at DESC, created_at DESC`
// 那个 (top_at IS NULL) 是表达式索引satisfy不了会强制全表排序。
// 现在新排序不用 top_at 了,这个坑不应该再出现。
const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const orderByLines = src.split('\n').filter((l) => /ORDER BY|orderBy =/.test(l));
for (const line of orderByLines) {
assert.ok(
!/top_at\s+IS\s+NULL\s*\)\s*,/.test(line),
`排序表达式又回来了,会导致全表排序: ${line.trim()}`
);
}
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,73 @@
'use strict';
const assert = require('node:assert');
const { shouldReadImage } = require('../lib/poll-guard');
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; }
}
check('纯文本时返回 false', () => {
assert.strictEqual(shouldReadImage(['text/plain']), false);
assert.strictEqual(shouldReadImage(['text/html']), false);
});
check('空数组返回 false', () => {
assert.strictEqual(shouldReadImage([]), false);
});
check('空格式列表返回 false', () => {
assert.strictEqual(shouldReadImage(['']), false);
});
check('纯图片返回 true', () => {
assert.strictEqual(shouldReadImage(['image/png']), true);
assert.strictEqual(shouldReadImage(['image/bmp']), true);
assert.strictEqual(shouldReadImage(['image/jpeg']), true);
});
check('文本+图片混合返回 true', () => {
assert.strictEqual(shouldReadImage(['text/plain', 'image/png']), true);
});
check('非数组容错', () => {
assert.strictEqual(shouldReadImage(null), false);
assert.strictEqual(shouldReadImage(undefined), false);
assert.strictEqual(shouldReadImage('image/png'), false);
});
check('非字符串元素容错', () => {
assert.strictEqual(shouldReadImage([null, 'image/png']), true);
assert.strictEqual(shouldReadImage([42, 'text/plain']), false);
});
check('只匹配 image/ 开头,不被 text/image 之类误判', () => {
assert.strictEqual(shouldReadImage(['text/image']), false);
});
// ---------- 主进程接入回归 ----------
const fs = require('node:fs');
const path = require('node:path');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
check('main.js 已 require poll-guard', () => {
assert.ok(
/require\(\s*'\.\/lib\/poll-guard'\s*\)/.test(mainSrc),
'main.js 没 require poll-guard'
);
});
check('pollClipboard 在 readImage 前调用了 shouldReadImage', () => {
// 抓出 pollClipboard 整个函数体
const fn = mainSrc.match(/function pollClipboard[\s\S]*?\n\}/);
assert.ok(fn, '找不到 pollClipboard');
const idxGuard = fn[0].indexOf('shouldReadImage');
const idxRead = fn[0].indexOf('readImage');
assert.ok(idxGuard > 0, 'pollClipboard 没调用 shouldReadImage');
assert.ok(idxRead > 0, 'pollClipboard 没调用 readImage');
assert.ok(idxGuard < idxRead, 'shouldReadImage 必须在 readImage 之前出现');
});
console.log(`\n${passed} checks passed`);

67
test/run.js Normal file
View File

@@ -0,0 +1,67 @@
'use strict';
/**
* 测试入口。
*
* 为什么要有这个文件,而不是在 package.json 里串一长串 &&
*
* 1. ABI。better-sqlite3 是原生模块,只能编译给一个运行时。仓库里它是按
* Electron 的 ABI 编译的postinstall / npm run rebuild所以用普通
* `node` 跑任何碰 DB 的测试都会直接 ERR_DLOPEN_FAILED。
* 解法:用 ELECTRON_RUN_AS_NODE=1 借 Electron 自带的 node 来跑 ——
* ABI 天然对得上,而且测的就是应用真正加载的那个二进制。
*
* 2. 手写清单会漏。settings-render.test.js 就这么被漏掉过:文件在、
* 专门防一类回归,但从来没被执行。这里改成扫目录,新加的测试自动进来。
*/
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const testDir = __dirname;
// 需要真实 Electron GUIBrowserWindow / app走 npm run test:ui
const GUI_ONLY = new Set(['settings-tabs.test.js', 'settings-render.test.js']);
// 用 node:test 运行器的
const NODE_TEST_RUNNER = new Set(['theme.test.js']);
const files = fs.readdirSync(testDir)
.filter((f) => f.endsWith('.test.js') && !GUI_ONLY.has(f))
.sort();
if (files.length === 0) {
console.error('没有找到任何测试文件');
process.exit(1);
}
function electronBin() {
try {
return require('electron'); // 返回可执行文件的绝对路径
} catch (_) {
return null;
}
}
const bin = electronBin();
if (!bin || typeof bin !== 'string') {
console.error('找不到 electron。先跑 npm install。');
process.exit(1);
}
const failed = [];
for (const f of files) {
const args = NODE_TEST_RUNNER.has(f) ? ['--test', path.join(testDir, f)] : [path.join(testDir, f)];
console.log(`\n─── ${f} ${'─'.repeat(Math.max(0, 56 - f.length))}`);
const r = spawnSync(bin, args, {
stdio: 'inherit',
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
if (r.status !== 0) failed.push(f);
}
console.log('\n' + '═'.repeat(64));
if (failed.length) {
console.error(`${failed.length}/${files.length} 个测试文件失败:`);
for (const f of failed) console.error(' - ' + f);
process.exit(1);
}
console.log(`${files.length} 个测试文件全部通过`);

View File

@@ -0,0 +1,35 @@
'use strict';
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 tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-mig-'));
const db = new Database(path.join(tmp, 'h.db'));
// Simulate an old DB without FTS5.
db.exec(`CREATE TABLE clips (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT)`);
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; }
}
check('migrate from v1 runs without throwing', () => {
migrate(db, 1, 2);
});
let skipped = false;
try { db.prepare('SELECT 1 FROM clips_fts LIMIT 0').all(); }
catch (_) { skipped = true; }
check(skipped ? 'clips_fts not present (FTS5 unavailable)' : 'clips_fts present', () => {
if (!skipped) {
const rows = db.prepare('SELECT 1 FROM clips_fts LIMIT 0').all();
assert.deepStrictEqual(rows, []);
}
});
db.close();

254
test/search-plan.test.js Normal file
View File

@@ -0,0 +1,254 @@
'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`);

View File

@@ -0,0 +1,45 @@
'use strict';
const assert = require('node:assert');
const { SETTINGS_HANDLERS, applyPatch } = require('../lib/settings-handlers.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; }
}
check('autoLaunch handler coerces truthy to boolean', () => {
const r = applyPatch({ autoLaunch: false }, { key: 'autoLaunch', value: 'yes' });
assert.strictEqual(r.autoLaunch, true);
});
check('maxItems accepts valid number', () => {
const r = applyPatch({ maxItems: 500 }, { key: 'maxItems', value: 500 });
assert.strictEqual(r.maxItems, 500);
});
check('displayLimit accepts valid enum and falls back on invalid', () => {
const r1 = applyPatch({ displayLimit: 300 }, { key: 'displayLimit', value: 500 });
assert.strictEqual(r1.displayLimit, 500);
const r2 = applyPatch({}, { key: 'displayLimit', value: 9999 });
assert.strictEqual(r2.displayLimit, 300);
});
check('unknown key is a no-op (returns shallow copy)', () => {
const r = applyPatch({}, { key: 'unknown', value: 1 });
assert.deepStrictEqual(r, {});
});
check('dataDir 刻意没有 handler只能走 settings:choose-dir', () => {
const r = applyPatch({ dataDir: 'D:\\Old' }, { key: 'dataDir', value: 'D:\\New' });
assert.strictEqual(r.dataDir, 'D:\\Old', 'settings:set dataDir 必须是 no-op否则运行中的库与配置分叉');
});
check('hotkey accepts preset values and falls back on invalid', () => {
const r1 = applyPatch({ hotkey: 'Ctrl+Alt+V' }, { key: 'hotkey', value: 'Alt+Shift+V' });
assert.strictEqual(r1.hotkey, 'Alt+Shift+V');
const r2 = applyPatch({ hotkey: 'Ctrl+Alt+V' }, { key: 'hotkey', value: '' });
assert.strictEqual(r2.hotkey, '', '空串是合法的「禁用」');
const r3 = applyPatch({ hotkey: 'Ctrl+Alt+V' }, { key: 'hotkey', value: 'Super+X' });
assert.strictEqual(r3.hotkey, 'Ctrl+Alt+V', '白名单外回落默认');
});

View File

@@ -0,0 +1,107 @@
'use strict';
/**
* 回归测试:设置窗口必须真正渲染出配置内容(尤其是"数据保存位置"路径)。
*
* 守护两类曾经发生过的静默失败:
* 1. settings.js 顶层 `const themeApi` 与 contextBridge 暴露的不可配置全局属性
* window.themeApi 同名 → 脚本实例化阶段抛 SyntaxError整个文件一行都不执行。
* 2. 重构时误删了末尾的 refresh() 调用 → 函数定义了却从未调用,界面全空白。
*
* 两种情况下界面都"看起来正常"但内容为空,只有断言 DOM 实际文本才能发现。
* 运行npx electron test/settings-render.test.js
*/
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('node:path');
const assert = require('node:assert');
const DATA_DIR = 'C:\\Users\\probe\\.clipboard-app';
const cfg = {
// 只用 config.js ALLOWED_* 里的合法值,且必须是 settings.html 里存在的 <option>
autoLaunch: false, maxItems: 0, displayLimit: 300, pollMs: 1000,
hotkey: 'Ctrl+Alt+V',
dataDir: DATA_DIR, theme: 'linear', accent: 'purple', followSystem: false,
};
ipcMain.handle('settings:get', () => ({ ...cfg }));
ipcMain.handle('settings:set', () => ({ ...cfg }));
ipcMain.handle('settings:clear-history', () => 0);
ipcMain.handle('settings:open-dir', () => {});
ipcMain.handle('settings:choose-dir', () => ({ ok: false }));
ipcMain.handle('theme:get', () => ({ ...cfg }));
ipcMain.handle('theme:set', (_e, { theme }) => ({ theme, accent: cfg.accent }));
ipcMain.handle('theme:set-accent', (_e, { accent }) => ({ theme: cfg.theme, accent }));
ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg.accent }));
// settings.js 的 initTheme() 会订阅主题变更stub 里缺这个 handler 时
// invoke 会抛错,渲染层的 catch 打 console.error被下面的"无脚本错误"
// 断言当成 SyntaxError 一类的致命问题误杀。
ipcMain.handle('theme:subscribe', () => true);
let failed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); }
catch (e) { console.error(' FAIL -', name, '\n ', e.message); failed++; }
}
app.whenReady().then(async () => {
// 与 main.js openSettings() 一致的窗口尺寸
const win = new BrowserWindow({
width: 420, height: 690, show: false, frame: false, resizable: false,
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true, nodeIntegration: false, sandbox: false,
},
});
const errors = [];
win.webContents.on('console-message', (_e, level, message) => {
// level 3 = error
if (level === 3) errors.push(message);
});
await win.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
await new Promise((r) => setTimeout(r, 900));
const state = await win.webContents.executeJavaScript(`(() => {
const dir = document.getElementById('opt-datadir');
const grid = document.getElementById('theme-grid');
const accents = document.getElementById('accent-grid');
return {
dirText: dir ? dir.textContent : null,
themeCards: grid ? grid.children.length : -1,
accentSwatches: accents ? accents.children.length : -1,
maxItems: (document.getElementById('opt-maxitems')||{}).value,
displayLimit: (document.getElementById('opt-displaylimit')||{}).value,
pollMs: (document.getElementById('opt-pollms')||{}).value,
hotkey: (document.getElementById('opt-hotkey')||{}).value,
};
})()`);
console.log('设置窗口渲染回归测试:');
check('settings.js 无脚本错误SyntaxError 会让整个文件不执行)', () => {
assert.deepStrictEqual(errors, []);
});
check('"数据保存位置" 渲染出真实路径', () => {
assert.strictEqual(state.dirText, DATA_DIR);
});
check('主题网格已渲染出 6 张卡片', () => {
assert.strictEqual(state.themeCards, 6);
});
check('强调色已渲染出 5 个色块', () => {
assert.strictEqual(state.accentSwatches, 5);
});
check('最大保留条数已回填配置值', () => {
assert.strictEqual(state.maxItems, '0');
});
check('界面显示条数已回填配置值', () => {
assert.strictEqual(state.displayLimit, '300');
});
check('轮询间隔已回填配置值', () => {
assert.strictEqual(state.pollMs, '1000');
});
check('唤起快捷键已回填配置值', () => {
assert.strictEqual(state.hotkey, 'Ctrl+Alt+V');
});
console.log(failed === 0 ? '\n全部通过' : `\n${failed} 项失败`);
app.exit(failed === 0 ? 0 : 1);
});

107
test/settings-tabs.test.js Normal file
View File

@@ -0,0 +1,107 @@
'use strict';
// 在真实 Electron 窗口尺寸(420x690与 main.js openSettingsWindow 一致)下
// 验证设置窗口的标签页与布局。
// 运行electron test/settings-tabs.test.js
const assert = require('node:assert');
const path = require('node:path');
const { app, BrowserWindow, ipcMain } = require('electron');
const cfg = {
autoLaunch: false, maxItems: 0, displayLimit: 300, pollMs: 1000,
dataDir: 'C:\\Users\\guan\\.clipboard-app',
theme: 'linear', accent: 'purple', followSystem: false,
};
ipcMain.handle('settings:get', () => ({ ...cfg }));
ipcMain.handle('settings:set', () => ({ ...cfg }));
ipcMain.handle('settings:clear-history', () => 0);
ipcMain.handle('settings:open-dir', () => {});
ipcMain.handle('settings:choose-dir', () => ({ ok: false }));
ipcMain.handle('theme:get', () => ({ ...cfg }));
ipcMain.handle('theme:set', (_e, { theme }) => ({ theme, accent: cfg.accent }));
ipcMain.handle('theme:set-accent', (_e, { accent }) => ({ theme: cfg.theme, accent }));
ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg.accent }));
// 与 settings-render.test.js 同理settings.js 启动时会订阅主题变更,
// stub 缺这个 handler 会往终端打一条未捕获错误(此测试不断言 console
// 但留着噪音会掩盖真问题)。
ipcMain.handle('theme:subscribe', () => true);
let passed = 0;
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; }
}
app.whenReady().then(async () => {
const win = new BrowserWindow({
width: 420, height: 690, show: false, frame: false, resizable: false,
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true, nodeIntegration: false, sandbox: false,
},
});
await win.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
await new Promise((r) => setTimeout(r, 900));
const js = (code) => win.webContents.executeJavaScript(code);
await check('三个标签存在且文案顺序正确', async () => {
const labels = JSON.parse(await js(
`JSON.stringify([...document.querySelectorAll('#tab-bar [role="tab"]')].map(t => t.textContent.trim()))`
));
assert.deepStrictEqual(labels, ['外观', '通用', '关于']);
});
await check('初始只显示「外观」页', async () => {
const r = JSON.parse(await js(`JSON.stringify({
visible: [...document.querySelectorAll('.tab-panel')].filter(p => !p.hidden).map(p => p.id),
selected: document.querySelector('[role="tab"][aria-selected="true"]').textContent.trim(),
})`));
assert.deepStrictEqual(r.visible, ['panel-appearance']);
assert.strictEqual(r.selected, '外观');
});
await check('点击「通用」切到通用页并互斥', async () => {
const r = JSON.parse(await js(`(() => {
document.getElementById('tab-general').click();
return JSON.stringify({
visible: [...document.querySelectorAll('.tab-panel')].filter(p => !p.hidden).map(p => p.id),
cur: document.getElementById('tab-general').getAttribute('aria-selected'),
prev: document.getElementById('tab-appearance').getAttribute('aria-selected'),
});
})()`));
assert.deepStrictEqual(r.visible, ['panel-general']);
assert.strictEqual(r.cur, 'true');
assert.strictEqual(r.prev, 'false');
});
await check('→ 键切到下一个标签,← 键回上一个', async () => {
const r = JSON.parse(await js(`(() => {
const bar = document.getElementById('tab-bar');
const cur = () => document.querySelector('[role="tab"][aria-selected="true"]').textContent.trim();
const key = (k) => bar.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
document.getElementById('tab-appearance').click();
key('ArrowRight'); const right = cur();
key('ArrowLeft'); const back = cur();
return JSON.stringify({ right, back });
})()`));
assert.strictEqual(r.right, '通用');
assert.strictEqual(r.back, '外观');
});
await check('每一页在 690px 窗口内都放得下(不需要滚动)', async () => {
const rows = JSON.parse(await js(`JSON.stringify(
[...document.querySelectorAll('#tab-bar [role="tab"]')].map(t => {
t.click();
const p = document.getElementById(t.getAttribute('aria-controls'));
return { id: p.id, clientH: p.clientHeight, scrollH: p.scrollHeight };
})
)`));
for (const r of rows) {
assert.ok(r.scrollH <= r.clientH + 1,
`${r.id} 内容溢出:${r.scrollH}px > 可视 ${r.clientH}px`);
}
});
console.log(`\n${passed} checks passed`);
app.exit(process.exitCode ? 1 : 0);
});

31
test/theme-bg.test.js Normal file
View File

@@ -0,0 +1,31 @@
'use strict';
const assert = require('node:assert');
const { themeBgColor } = require('../config');
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; }
}
const expected = {
linear: '#0E0E12',
midnight: '#08090B',
carbon: '#1A1D23',
solarized: '#002B36',
github: '#0D1117',
light: '#FAFAFA',
};
for (const [theme, bg] of Object.entries(expected)) {
check(`themeBgColor('${theme}') === ${bg}`, () => {
assert.strictEqual(themeBgColor(theme), bg);
});
}
check('未知 theme 兜底为 light 背景', () => {
assert.strictEqual(themeBgColor('unknown'), '#FAFAFA');
assert.strictEqual(themeBgColor(''), '#FAFAFA');
assert.strictEqual(themeBgColor(null), '#FAFAFA');
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,117 @@
'use strict';
/**
* 「跟随系统」开 / 关 时主题显示一致性的接线测试。
*
* 历史问题theme:get / applyThemeChange 旧实现返回的是 appConfig.themeraw
* 渲染端拿到后直接设 data-theme ——「跟随系统」开着的状态下,初次打开设置窗口
* 显示的是 raw 主题(比如 midnight只有等到系统主题切换广播来才换。
* settings 窗口根本收不到那条广播(没切换),于是永远显示错的。
*
* 修法main.js 返回 effectiveTheme带 rawTheme 字段兜底),渲染端按
* effectiveTheme 设 DOM。theme-boot 也读 effectiveTheme 以避免冷启动闪。
*
* 这里钉住三件事,避免后续改动把 effective 字段删了回退到 raw
* 1. main.js 的 theme:get / applyThemeChange / broadcastTheme 三个出口
* 都给 effectiveTheme 字段;
* 2. 渲染端renderer + settings优先用 effectiveTheme 设置 data-theme
* 3. theme-boot.js 在缓存命中时也用 effectiveTheme。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const mainSrc = read('main.js');
const rendererSrc = read('renderer/renderer.js');
const settingsSrc = read('renderer/settings.js');
const themeBootSrc = read('renderer/theme-boot.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; }
}
// ---------- 主进程:所有主题出口都带 effectiveTheme ----------
check('config.effectiveTheme 存在并被 main.js 引用', () => {
assert.match(mainSrc, /configMod\.effectiveTheme\s*\(/,
'main.js 没用 configMod.effectiveTheme —— 跟随系统仍可能在 IPC 出口回退到 raw theme');
});
check('broadcastTheme 广播 effectiveTheme 字段', () => {
const fn = mainSrc.match(/function\s+broadcastTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 broadcastTheme');
assert.match(fn[0], /effectiveTheme\s*:/,
'broadcastTheme 没带 effectiveTheme 字段,订阅的窗口拿不到跟随系统状态');
});
check('applyThemeChange 返回值带 effectiveTheme', () => {
const fn = mainSrc.match(/function\s+applyThemeChange\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 applyThemeChange');
assert.match(fn[0], /effectiveTheme\s*:/,
'applyThemeChange 返回值没带 effectiveTheme —— settings.js 收到 raw theme 会盖掉刚广播的 effective');
});
check('theme:get 处理器返回 effectiveTheme', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'theme:get'[\s\S]*?\n\s{2}\}\s*\)\s*;/);
assert.ok(handler, '找不到 theme:get 处理器');
assert.match(handler[0], /effectiveTheme/,
'theme:get 返回值没带 effectiveTheme —— 启动时设置窗口 / 主窗口会显示 raw 主题');
});
// ---------- 渲染端:用 effectiveTheme 设 data-theme ----------
check('renderer.js 的 applyTheme 用 effectiveTheme 设 data-theme', () => {
const fn = rendererSrc.match(/async\s+function\s+applyTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 applyTheme');
// 必须显式读 cfg.effectiveTheme 而不是 cfg.theme
assert.match(fn[0], /cfg\.effectiveTheme\s*\|\|\s*cfg\.theme/,
'applyTheme 没读 effectiveTheme ——「跟随系统」开时 data-theme 是 raw 主题');
});
check('renderer.js 的 applyTheme 在 localStorage 里同时缓存 raw 和 effective', () => {
// theme-boot 启动读 cache需要 effectiveTheme 才能避免冷启动闪
const fn = rendererSrc.match(/async\s+function\s+applyTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 applyTheme');
assert.match(fn[0], /rawTheme[\s\S]*?effectiveTheme/,
'applyTheme 没把 raw 和 effective 同时写 localStorage —— 启动会有闪');
});
check('renderer.js 的 onSystemChanged 不再写 localStorage避免覆盖 raw', () => {
const fn = rendererSrc.match(/onSystemChanged\(\s*\(payload\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(fn, '找不到 onSystemChanged 回调');
// 注释也明确写了为什么不写payload 里没 rawTheme
assert.ok(
!/localStorage\.setItem/.test(fn[0]),
'onSystemChanged 在写 localStorage —— payload 没有 rawTheme下次启动会拿不到 raw'
);
// DOM 更新仍然要用 effective
assert.match(fn[0], /effectiveTheme\s*\|\|\s*payload\.theme/,
'onSystemChanged 没读 effectiveTheme');
});
check('settings.js initTheme 用 effectiveTheme', () => {
const fn = settingsSrc.match(/async\s+function\s+initTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 initTheme');
assert.match(fn[0], /cfg\.effectiveTheme\s*\|\|\s*cfg\.theme/,
'initTheme 没读 effectiveTheme —— 设置窗口初次打开「跟随系统」开时显示 raw');
});
check('settings.js onSystemChanged 用 effectiveTheme', () => {
const fn = settingsSrc.match(/onSystemChanged\(\s*\(payload\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(fn, '找不到 settings.js onSystemChanged');
assert.match(fn[0], /effectiveTheme\s*\|\|\s*payload\.theme/,
'settings.js onSystemChanged 没读 effectiveTheme');
});
// ---------- theme-boot冷启动也用 effectiveTheme 避免闪 ----------
check('theme-boot.js 优先读 effectiveTheme避免「跟随系统」开时冷启动闪', () => {
assert.match(themeBootSrc, /effectiveTheme\s*\|\|\s*cached\.theme/,
'theme-boot.js 没读 effectiveTheme —— 冷启动在「跟随系统」开时会闪一下 raw 主题');
});
console.log(`\n${passed} checks passed`);

67
test/theme.test.js Normal file
View File

@@ -0,0 +1,67 @@
'use strict';
const assert = require('node:assert');
const { test } = require('node:test');
const configMod = require('../config');
test('DEFAULTS 包含 theme/accent/followSystem', () => {
assert.strictEqual(configMod.DEFAULTS.theme, 'linear');
assert.strictEqual(configMod.DEFAULTS.accent, 'purple');
assert.strictEqual(configMod.DEFAULTS.followSystem, false);
});
test('sanitize 接受合法 theme', () => {
assert.strictEqual(configMod.sanitize({ theme: 'midnight' }).theme, 'midnight');
assert.strictEqual(configMod.sanitize({ theme: 'github' }).theme, 'github');
});
test('sanitize 拒绝未知 theme 回落 linear', () => {
assert.strictEqual(configMod.sanitize({ theme: 'foo' }).theme, 'linear');
assert.strictEqual(configMod.sanitize({ theme: null }).theme, 'linear');
assert.strictEqual(configMod.sanitize({}).theme, 'linear');
});
test('sanitize 接受 5 种 accent', () => {
for (const a of ['purple', 'blue', 'green', 'orange', 'red']) {
assert.strictEqual(configMod.sanitize({ accent: a }).accent, a);
}
});
test('sanitize 拒绝未知 accent 回落 purple', () => {
assert.strictEqual(configMod.sanitize({ accent: 'cyan' }).accent, 'purple');
});
test('sanitize 接受 followSystem=true', () => {
assert.strictEqual(configMod.sanitize({ followSystem: true }).followSystem, true);
assert.strictEqual(configMod.sanitize({ followSystem: false }).followSystem, false);
assert.strictEqual(configMod.sanitize({ followSystem: 'yes' }).followSystem, false);
});
// ---------- effectiveTheme跟随系统开关与系统明暗组合 ----------
// 旧版把这段逻辑内联在 main.js 的 broadcastTheme / IPC 处理器里,
// 跟「跟随系统」开关的 IPC 顺序竞争出过显示主题不一致的问题。把判断
// 收到 config.effectiveTheme 后这里能直接单测,确保 4 个组合都正确。
test('effectiveThemefollowSystem=false 时用 raw theme', () => {
const cfg = { theme: 'midnight', followSystem: false };
assert.strictEqual(configMod.effectiveTheme(cfg, true), 'midnight');
assert.strictEqual(configMod.effectiveTheme(cfg, false), 'midnight');
});
test('effectiveThemefollowSystem=true + 深色系统 = linear', () => {
const cfg = { theme: 'midnight', followSystem: true };
assert.strictEqual(configMod.effectiveTheme(cfg, true), 'linear');
});
test('effectiveThemefollowSystem=true + 浅色系统 = light', () => {
const cfg = { theme: 'midnight', followSystem: true };
assert.strictEqual(configMod.effectiveTheme(cfg, false), 'light');
});
test('effectiveThemeraw theme 缺失时回落到默认 linear', () => {
// 极端防御cfg 为 null/undefined 时不让应用崩。
// followSystem 也没有 → 视同关闭,回到 DEFAULTS.themelinear
// 关键是不抛异常 + 返回合法值,不让调用方去 switch-case 'linear' 之类。
assert.strictEqual(configMod.effectiveTheme(null, true), 'linear');
assert.strictEqual(configMod.effectiveTheme(undefined, false), 'linear');
assert.strictEqual(configMod.effectiveTheme({ followSystem: false }, false), 'linear');
});

51
test/tray-menu.test.js Normal file
View File

@@ -0,0 +1,51 @@
'use strict';
const assert = require('node:assert');
const { buildTrayMenuTemplate } = require('../tray-menu.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
const noop = () => {};
const actions = { onShow: noop, onSettings: noop, onQuit: noop };
check('菜单只有 3 个可点项', () => {
const items = buildTrayMenuTemplate(actions).filter((i) => i.type !== 'separator');
assert.strictEqual(items.length, 3,
`期望 3 项,实际 ${items.length}${items.map((i) => i.label).join(' / ')}`);
});
check('菜单项顺序与文案固定', () => {
const t = buildTrayMenuTemplate(actions);
assert.deepStrictEqual(
t.map((i) => (i.type === 'separator' ? '---' : i.label)),
['显示历史', '设置...', '---', '退出']
);
});
check('不再包含主题 / 强调色 / 快捷键 / 关于', () => {
const labels = buildTrayMenuTemplate(actions).map((i) => i.label || '').join('|');
for (const gone of ['主题', '强调色', '快捷键', '关于']) {
assert.ok(!labels.includes(gone), `${gone}」应已迁移到设置窗口`);
}
});
check('没有任何子菜单', () => {
const t = buildTrayMenuTemplate(actions);
assert.ok(t.every((i) => !i.submenu), '托盘不应再有子菜单');
});
check('click 回调按顺序接线到传入的动作', () => {
const calls = [];
const t = buildTrayMenuTemplate({
onShow: () => calls.push('show'),
onSettings: () => calls.push('settings'),
onQuit: () => calls.push('quit'),
});
for (const i of t) if (typeof i.click === 'function') i.click();
assert.deepStrictEqual(calls, ['show', 'settings', 'quit']);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,60 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
// Pure file-content check (this test doesn't require electron).
const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
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; }
}
check('main.js contains exactly one createFramelessWindow definition', () => {
const re = /function createFramelessWindow\s*\(/g;
const matches = main.match(re) || [];
assert.strictEqual(matches.length, 1, `found ${matches.length} occurrences, expected 1`);
});
check('createMainWindow and openSettingsWindow both call createFramelessWindow', () => {
const calls = main.match(/createFramelessWindow\s*\(/g) || [];
// 1 definition + at least 2 callers (main + settings) = 3+ occurrences
assert.ok(calls.length >= 3, `expected >=3 occurrences, got ${calls.length}`);
});
check('createFramelessWindow 不再硬编码 #FFFFFF', () => {
// 抓出 createFramelessWindow 的整个函数体
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.ok(
!/backgroundColor:\s*['"]#FFFFFF['"]/.test(fn[0]),
'createFramelessWindow 还硬编码 #FFFFFF 首帧背景色'
);
});
check('createFramelessWindow 引用 themeBgColor', () => {
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.ok(
/themeBgColor\s*\(/.test(fn[0]),
'createFramelessWindow 没引用 themeBgColor'
);
});
check('createFramelessWindow 拒绝新开窗口请求(外链不能弹裸窗口)', () => {
// 关于页链接走 shell.openExternal这里是纵深防御漏过点击拦截的
// 打开方式(中键 / 拖拽)也不能弹出一个没有 preload 的外部窗口。
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.match(fn[0], /setWindowOpenHandler\(/, '未接线 setWindowOpenHandler');
assert.match(fn[0], /action:\s*['"]deny['"]/, '新开窗口未被拒绝');
});
check('createFramelessWindow 拦截页内导航(外部 URL 不能把窗口顶掉)', () => {
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.match(fn[0], /will-navigate/, '未接线 will-navigate');
assert.match(fn[0], /preventDefault\(\)/, '导航未被阻止');
});

View File

@@ -0,0 +1,86 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const rendererSrc = fs.readFileSync(path.join(__dirname, '..', 'renderer', 'renderer.js'), 'utf8');
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; }
}
const toggleWindow = mainSrc.match(/function toggleWindow\s*\([\s\S]*?\n\}/);
check('toggleWindow 函数存在', () => {
assert.ok(toggleWindow, '找不到 toggleWindow');
});
check('toggleWindow 在 show() 之前调用 positionAtTopCenter', () => {
const fn = toggleWindow[0];
// 两次 show() 调用的索引位置
const showIndices = [];
let from = 0;
while (true) {
const i = fn.indexOf('mainWindow.show()', from);
if (i < 0) break;
showIndices.push(i);
from = i + 1;
}
assert.ok(showIndices.length >= 2, `期望至少 2 处 mainWindow.show(),找到 ${showIndices.length}`);
const posIndices = [];
from = 0;
while (true) {
const i = fn.indexOf('positionAtTopCenter()', from);
if (i < 0) break;
posIndices.push(i);
from = i + 1;
}
assert.strictEqual(posIndices.length, showIndices.length,
`positionAtTopCenter 调用次数(${posIndices.length}) 应与 show 调用次数(${showIndices.length}) 相同`);
for (let k = 0; k < showIndices.length; k++) {
assert.ok(posIndices[k] < showIndices[k],
`${k + 1}positionAtTopCenter 必须在 show 之前 (pos=${posIndices[k]}, show=${showIndices[k]})`);
}
});
// ---------- renderer 端Esc / × 走 hide(),不再销毁窗口 ----------
check('renderer.js 已解构 hide来自 preload', () => {
assert.ok(
/const\s*\{[\s\S]*?\bhide\b[\s\S]*?\}\s*=\s*window\.api/.test(rendererSrc),
'renderer.js 没从 window.api 解构 hide()'
);
});
check('Esc 路径调用 hide(),不再 window.close()', () => {
// 抓出 Escape 分支整个 if 块(包含 body
const escBlock = rendererSrc.match(/if\s*\(\s*e\.key\s*===\s*'Escape'\s*\)\s*\{[\s\S]*?\n\s{2}\}/);
assert.ok(escBlock, '找不到 Escape 分支');
assert.ok(
/\bhide\s*\(/.test(escBlock[0]),
'Escape 分支没调 hide()'
);
assert.ok(
!/window\.close\s*\(/.test(escBlock[0]),
'Escape 分支还在用 window.close()'
);
});
check('× 按钮($closeBtnclick 调用 hide(),不再 window.close()', () => {
const onClick = rendererSrc.match(/\$closeBtn\.addEventListener\(\s*'click'[\s\S]*?\}\s*\)\s*;/);
assert.ok(onClick, '找不到 $closeBtn click 处理');
assert.ok(
/\bhide\s*\(/.test(onClick[0]),
'$closeBtn click 没调 hide()'
);
assert.ok(
!/window\.close\s*\(/.test(onClick[0]),
'$closeBtn click 还在用 window.close()'
);
});
console.log(`\n${passed} checks passed`);