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

177
test/clip-limit.test.js Normal file
View File

@@ -0,0 +1,177 @@
'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 个实参必须是 nulltop_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`);