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