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

View File

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