Files
Clipboard/test/clip-limit.test.js
2026-09-12 13:59:12 +08:00

178 lines
7.4 KiB
JavaScript
Raw 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';
/**
* 入库绑定 + 限额清理:应用里唯一会主动删用户数据的两条路径。
*
* 起因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`);