'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`);