update
This commit is contained in:
20
test/adaptive-poll.test.js
Normal file
20
test/adaptive-poll.test.js
Normal 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);
|
||||
});
|
||||
62
test/choose-dir-path.test.js
Normal file
62
test/choose-dir-path.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
// 回归测试:选择与当前相同目录的不同大小写形式,应当判为"已是当前数据位置"。
|
||||
//
|
||||
// 被测对象是 lib/same-dir.js —— main.js(settings: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
177
test/clip-limit.test.js
Normal 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 个实参必须是 null(top_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`);
|
||||
91
test/clip-list-prepend.test.js
Normal file
91
test/clip-list-prepend.test.js
Normal 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() 还在做 cloneNode,buildCard 抽得不够彻底'
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- 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
64
test/clip-payload.test.js
Normal file
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 回归测试:图片条目复制到剪贴板
|
||||
*
|
||||
* 曾经的 bug:copyClipToClipboard 先 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
110
test/config.test.js
Normal 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-app;2) 环境变量能覆盖。
|
||||
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 盘', () => {
|
||||
// 默认目录的契约有两个 case:D 盘存在 → 首选 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`);
|
||||
141
test/data-dir-fallback.test.js
Normal file
141
test/data-dir-fallback.test.js
Normal 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 盘不存在」的 mock:fs.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
44
test/db-prepared.test.js
Normal 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
182
test/echo-suppress.test.js
Normal 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 之间用户在外面复制了 B,tick 直接读到 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('resetBaseline:1×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
84
test/echo-wiring.test.js
Normal 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`);
|
||||
221
test/fav-copy-bridge.test.js
Normal file
221
test/fav-copy-bridge.test.js
Normal 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,再 bridge;bridge 必须放在 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`);
|
||||
70
test/fav-list-limit.test.js
Normal file
70
test/fav-list-limit.test.js
Normal 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 改由配置驱动(默认 300),renderer 不再硬编码 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`);
|
||||
21
test/favorites-cap.test.js
Normal file
21
test/favorites-cap.test.js
Normal 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
247
test/favorites.test.js
Normal 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 之后
|
||||
// 失败会变成 unhandledRejection(summary 还先把这条计进了 passed)。
|
||||
async function check(name, fn) {
|
||||
try { await fn(); console.log(' ok -', name); passed++; }
|
||||
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-fav-'));
|
||||
const dbPath = path.join(tmpHome, 'favorites.db');
|
||||
|
||||
check('open 创建 db 文件并返回实例', () => {
|
||||
const db = fav.open(dbPath);
|
||||
assert.ok(db, 'db 应该非空');
|
||||
assert.ok(fs.existsSync(dbPath), 'db 文件应存在');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('建表:favorites 表存在且列正确', () => {
|
||||
const db = fav.open(dbPath);
|
||||
const cols = db.prepare("PRAGMA table_info(favorites)").all();
|
||||
const names = cols.map(c => c.name);
|
||||
assert.deepStrictEqual(names, ['id','type','text','image','preview','source_clip_id','created_at','favorited_at']);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('索引 idx_fav_favorited_at 存在', () => {
|
||||
const db = fav.open(dbPath);
|
||||
const idx = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_fav_favorited_at'").get();
|
||||
assert.ok(idx, '索引应存在');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('source_clip_id 上有 UNIQUE 约束', () => {
|
||||
const db = fav.open(dbPath);
|
||||
const idx = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='favorites'").get();
|
||||
assert.ok(/UNIQUE\s*\(\s*source_clip_id\s*\)/i.test(idx.sql), '建表 SQL 应包含 UNIQUE(source_clip_id)');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('add 插入文本并能取回', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'add-text.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const now = Date.now() / 1000;
|
||||
const clip = { id: 101, type: 'text', text: 'hello world', preview: 'hello world', createdAt: now };
|
||||
const row = fav.add(db, clip);
|
||||
assert.ok(row.id > 0, '应返回新 id');
|
||||
assert.strictEqual(row.type, 'text');
|
||||
assert.strictEqual(row.text, 'hello world');
|
||||
assert.strictEqual(row.sourceClipId, 101);
|
||||
assert.strictEqual(row.preview, 'hello world');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('add 重复 source_clip_id 抛错', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'add-dup.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const now = Date.now() / 1000;
|
||||
const clip = { id: 102, type: 'text', text: 'first', preview: 'first', createdAt: now };
|
||||
fav.add(db, clip);
|
||||
let threw = false;
|
||||
try { fav.add(db, { ...clip, text: 'second' }); }
|
||||
catch (e) { threw = /UNIQUE/i.test(e.message); }
|
||||
assert.ok(threw, '重复 source_clip_id 应抛 UNIQUE 错误');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('add 插入图片并保留 BLOB', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'add-img.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const now = Date.now() / 1000;
|
||||
const buf = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // 假 PNG 头
|
||||
const clip = { id: 103, type: 'image', image: buf, preview: '[图片]', createdAt: now };
|
||||
const row = fav.add(db, clip);
|
||||
assert.strictEqual(row.type, 'image');
|
||||
// 列表/详情不再内联图片数据,只给一个 hasImage 标记;
|
||||
// 真正的字节走 getImage() 按需取(否则列表要背几百 MB 的 base64)
|
||||
assert.strictEqual(row.hasImage, true);
|
||||
assert.strictEqual(row.imageBase64, undefined, '不应再内联 base64');
|
||||
const b64 = fav.getImage(db, row.id);
|
||||
assert.strictEqual(typeof b64, 'string');
|
||||
assert.strictEqual(Buffer.compare(Buffer.from(b64, 'base64'), buf), 0);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('getImage 对文本条目返回 null', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'getimg-text.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const row = fav.add(db, { id: 201, type: 'text', text: 'x', preview: 'x', createdAt: Date.now()/1000 });
|
||||
assert.strictEqual(fav.getImage(db, row.id), null);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('count 返回条数且不加载 BLOB', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'count.db');
|
||||
const db = fav.open(dbPath2);
|
||||
assert.strictEqual(fav.count(db), 0);
|
||||
fav.add(db, { id: 301, type: 'text', text: 'a', preview: 'a', createdAt: Date.now()/1000 });
|
||||
fav.add(db, { id: 302, type: 'image', image: Buffer.from([1,2,3]), preview: '[图片]', createdAt: Date.now()/1000 });
|
||||
assert.strictEqual(fav.count(db), 2);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('list 不内联图片数据', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'list-noimg.db');
|
||||
const db = fav.open(dbPath2);
|
||||
fav.add(db, { id: 401, type: 'image', image: Buffer.from([9,9,9]), preview: '[图片]', createdAt: Date.now()/1000 });
|
||||
const rows = fav.list(db, '', 10);
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.strictEqual(rows[0].hasImage, true);
|
||||
assert.strictEqual(rows[0].imageBase64, undefined);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('isFavorited 返回 favId 或 null', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'isfav.db');
|
||||
const db = fav.open(dbPath2);
|
||||
assert.strictEqual(fav.isFavorited(db, 103), null, '未收藏');
|
||||
fav.add(db, { id: 104, type: 'text', text: 'x', preview: 'x', createdAt: Date.now()/1000 });
|
||||
const id = fav.isFavorited(db, 104);
|
||||
assert.ok(typeof id === 'number' && id > 0, '已收藏应返回数字 favId');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
(async () => {
|
||||
check('remove 删除存在的 fav', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'remove.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const row = fav.add(db, { id: 201, type: 'text', text: 'a', preview: 'a', createdAt: Date.now()/1000 });
|
||||
assert.strictEqual(fav.remove(db, row.id), true);
|
||||
assert.strictEqual(fav.get(db, row.id), null);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('remove 不存在的 fav 返回 false', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'remove-missing.db');
|
||||
const db = fav.open(dbPath2);
|
||||
assert.strictEqual(fav.remove(db, 99999), false);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('get 取单条', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'get.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const row = fav.add(db, { id: 202, type: 'text', text: 'b', preview: 'b', createdAt: Date.now()/1000 });
|
||||
const got = fav.get(db, row.id);
|
||||
assert.strictEqual(got.text, 'b');
|
||||
assert.strictEqual(got.id, row.id);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
await check('list 默认按 favorited_at DESC', async () => {
|
||||
const dbPath2 = path.join(tmpHome, 'list-order.db');
|
||||
const db = fav.open(dbPath2);
|
||||
fav.add(db, { id: 301, type: 'text', text: 'first', preview: 'first', createdAt: Date.now()/1000 });
|
||||
await sleep(10);
|
||||
fav.add(db, { id: 302, type: 'text', text: 'second', preview: 'second', createdAt: Date.now()/1000 });
|
||||
await sleep(10);
|
||||
fav.add(db, { id: 303, type: 'text', text: 'third', preview: 'third', createdAt: Date.now()/1000 });
|
||||
const all = fav.list(db);
|
||||
assert.strictEqual(all.length, 3);
|
||||
assert.strictEqual(all[0].text, 'third');
|
||||
assert.strictEqual(all[1].text, 'second');
|
||||
assert.strictEqual(all[2].text, 'first');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('list 支持 limit', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'list-limit.db');
|
||||
const db = fav.open(dbPath2);
|
||||
for (let i = 0; i < 5; i++) fav.add(db, { id: 400+i, type: 'text', text: 'x'+i, preview: 'x'+i, createdAt: Date.now()/1000 });
|
||||
assert.strictEqual(fav.list(db, '', 2).length, 2);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
// getRaw:主进程「复制收藏到系统剪贴板」的取数路径(favorites:copy)。
|
||||
// 必须带 image BLOB 且字段形状与 clips 表一致(type / text / image),
|
||||
// 才能直接喂 lib/clip-payload。
|
||||
check('getRaw 取文本收藏的原始行', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'getraw-text.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const row = fav.add(db, { id: 501, type: 'text', text: 'raw-text', preview: 'raw-text', createdAt: Date.now()/1000 });
|
||||
const raw = fav.getRaw(db, row.id);
|
||||
assert.ok(raw, '应取到原始行');
|
||||
assert.strictEqual(raw.type, 'text');
|
||||
assert.strictEqual(raw.text, 'raw-text');
|
||||
assert.strictEqual(raw.image, null);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('getRaw 取图片收藏时带回 BLOB', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'getraw-img.db');
|
||||
const db = fav.open(dbPath2);
|
||||
const buf = Buffer.from([0x89, 0x50, 0x4E, 0x47, 7, 7]);
|
||||
const row = fav.add(db, { id: 502, type: 'image', image: buf, preview: '[图片]', createdAt: Date.now()/1000 });
|
||||
const raw = fav.getRaw(db, row.id);
|
||||
assert.ok(raw, '应取到原始行');
|
||||
assert.strictEqual(raw.type, 'image');
|
||||
assert.ok(Buffer.isBuffer(raw.image), 'image 列应是 Buffer');
|
||||
assert.strictEqual(Buffer.compare(raw.image, buf), 0, 'BLOB 内容应原样取回');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('getRaw 对不存在的 favId 返回 null', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'getraw-missing.db');
|
||||
const db = fav.open(dbPath2);
|
||||
assert.strictEqual(fav.getRaw(db, 99999), null);
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
// ---------- 搜索 ----------
|
||||
// 与历史搜索同语义(lib/search-plan):多 token AND、% _ \ 转义。
|
||||
check('list 搜索:LIKE 元字符按字面匹配(% _ 不是通配符)', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'search-esc.db');
|
||||
const db = fav.open(dbPath2);
|
||||
fav.add(db, { id: 601, type: 'text', text: '100% off_sale promo', preview: '', createdAt: Date.now()/1000 });
|
||||
fav.add(db, { id: 602, type: 'text', text: '100 apples for sale', preview: '', createdAt: Date.now()/1000 });
|
||||
fav.add(db, { id: 603, type: 'text', text: 'aXb wildcard victim', preview: '', createdAt: Date.now()/1000 });
|
||||
assert.strictEqual(fav.list(db, '100%').length, 1, '% 不能当通配符匹配一切以 100 开头的内容');
|
||||
assert.strictEqual(fav.list(db, 'off_sale').length, 1, '_ 不能匹配任意单字符');
|
||||
assert.strictEqual(fav.list(db, 'a_b').length, 0, '没有字面含 a_b 的记录');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
check('list 搜索:多 token 是 AND、顺序无关', () => {
|
||||
const dbPath2 = path.join(tmpHome, 'search-and.db');
|
||||
const db = fav.open(dbPath2);
|
||||
fav.add(db, { id: 611, type: 'text', text: 'walk in the park', preview: '', createdAt: Date.now()/1000 });
|
||||
fav.add(db, { id: 612, type: 'text', text: 'park bench only', preview: '', createdAt: Date.now()/1000 });
|
||||
assert.strictEqual(fav.list(db, 'park walk').length, 1, '两个 token 都出现才命中');
|
||||
assert.strictEqual(fav.list(db, 'walk park').length, 1, '顺序无关');
|
||||
assert.strictEqual(fav.list(db, 'park missing').length, 0, '缺一个就不命中');
|
||||
fav.close(db);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} checks passed`);
|
||||
})();
|
||||
40
test/fts-search.test.js
Normal file
40
test/fts-search.test.js
Normal 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
23
test/image-cap.test.js
Normal 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
248
test/ipc-wiring.test.js
Normal 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.js,require('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`);
|
||||
67
test/keyhelper-reconnect.test.js
Normal file
67
test/keyhelper-reconnect.test.js
Normal 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
119
test/legacy-migrate.test.js
Normal 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
53
test/list-join.test.js
Normal 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();
|
||||
128
test/migrate-idempotent.test.js
Normal file
128
test/migrate-idempotent.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 回归测试:v1 -> v2 迁移
|
||||
*
|
||||
* 曾经的 bug:migrate() 用 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
28
test/notify-sets.test.js
Normal 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
186
test/order-by.test.js
Normal 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 DESC:f(400), b(300), d(200), c(200), e(100), a(100)
|
||||
// created_at 相同的 (d/c=200, e/a=100) 之间用 id DESC 决出次序:
|
||||
// - 同为 200:d(id=4) > c(id=3) → d 在前
|
||||
// - 同为 100:e(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_at,id 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`);
|
||||
73
test/poll-available-formats.test.js
Normal file
73
test/poll-available-formats.test.js
Normal 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
67
test/run.js
Normal 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 GUI(BrowserWindow / 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} 个测试文件全部通过`);
|
||||
35
test/schema-version.test.js
Normal file
35
test/schema-version.test.js
Normal 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
254
test/search-plan.test.js
Normal 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 / escapeLike:LIKE 元字符 ----------
|
||||
|
||||
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); // 先停在 v2(unicode61)
|
||||
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`);
|
||||
45
test/settings-handlers.test.js
Normal file
45
test/settings-handlers.test.js
Normal 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', '白名单外回落默认');
|
||||
});
|
||||
107
test/settings-render.test.js
Normal file
107
test/settings-render.test.js
Normal 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
107
test/settings-tabs.test.js
Normal 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
31
test/theme-bg.test.js
Normal 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`);
|
||||
117
test/theme-effective.test.js
Normal file
117
test/theme-effective.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 「跟随系统」开 / 关 时主题显示一致性的接线测试。
|
||||
*
|
||||
* 历史问题:theme:get / applyThemeChange 旧实现返回的是 appConfig.theme(raw),
|
||||
* 渲染端拿到后直接设 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
67
test/theme.test.js
Normal 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('effectiveTheme:followSystem=false 时用 raw theme', () => {
|
||||
const cfg = { theme: 'midnight', followSystem: false };
|
||||
assert.strictEqual(configMod.effectiveTheme(cfg, true), 'midnight');
|
||||
assert.strictEqual(configMod.effectiveTheme(cfg, false), 'midnight');
|
||||
});
|
||||
|
||||
test('effectiveTheme:followSystem=true + 深色系统 = linear', () => {
|
||||
const cfg = { theme: 'midnight', followSystem: true };
|
||||
assert.strictEqual(configMod.effectiveTheme(cfg, true), 'linear');
|
||||
});
|
||||
|
||||
test('effectiveTheme:followSystem=true + 浅色系统 = light', () => {
|
||||
const cfg = { theme: 'midnight', followSystem: true };
|
||||
assert.strictEqual(configMod.effectiveTheme(cfg, false), 'light');
|
||||
});
|
||||
|
||||
test('effectiveTheme:raw theme 缺失时回落到默认 linear', () => {
|
||||
// 极端防御:cfg 为 null/undefined 时不让应用崩。
|
||||
// followSystem 也没有 → 视同关闭,回到 DEFAULTS.theme(linear)。
|
||||
// 关键是不抛异常 + 返回合法值,不让调用方去 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
51
test/tray-menu.test.js
Normal 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`);
|
||||
60
test/window-factory.test.js
Normal file
60
test/window-factory.test.js
Normal 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\(\)/, '导航未被阻止');
|
||||
});
|
||||
86
test/window-lifecycle.test.js
Normal file
86
test/window-lifecycle.test.js
Normal 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('× 按钮($closeBtn)click 调用 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`);
|
||||
Reference in New Issue
Block a user