update
This commit is contained in:
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`);
|
||||
Reference in New Issue
Block a user