178 lines
7.4 KiB
JavaScript
178 lines
7.4 KiB
JavaScript
'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`);
|