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

221 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 「收藏 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`);