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

186
test/order-by.test.js Normal file
View File

@@ -0,0 +1,186 @@
'use strict';
/**
* 列表排序:验证 main.js 里 orderBy() 的实现。
*
* 排序契约:列表按「最近活跃」倒序,即 created_at DESC。
*
* - 外部新复制:插入时 created_at = now → 自然排到顶
* - 用户点过「复制」movetop 把 created_at 同步更新到 now → 同样排到顶
* - 其它项按 created_at DESC 自然排列
*
* 早期版本用过 `ORDER BY top_at DESC, created_at DESC`,但它让「外部新复制」
* 排在「会话内置顶项」之后 —— 即使新复制才是当前剪贴板内容,列表顶上
* 显示的还是上一会话里被点过复制的旧项。这违反「第一个就是当前剪贴板
* 内容」的契约。改成 created_at DESC 后,无论外部复制还是点过复制,
* 都按最近活跃时间排序,问题自然消失。
*
* 用 id DESC 作 tiebreaker同 ms 内多次变更给一个稳定次序。
*/
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 NEW = 'ORDER BY created_at DESC, id DESC';
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-ob-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips(
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT, image BLOB,
preview TEXT, top_at REAL, created_at REAL NOT NULL)`);
// 复合索引created_at DESC, id DESC —— 完整覆盖 ORDER BY 的两列,
// 不需要「TEMP B-TREE FOR LAST TERM OF ORDER BY」给 id DESC 兜底。
db.exec(`CREATE INDEX idx_clips_created_id ON clips(created_at DESC, id DESC)`);
return db;
}
const ids = (db, order, limit = 1000) =>
db.prepare(`SELECT id FROM clips ${order} LIMIT ?`).all(limit).map((r) => r.id);
check('外部新复制天然排到顶created_at 最大的在最前', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100); // id=1, created_at=100
ins.run('text', 'b', null, 300); // id=2, created_at=300
ins.run('text', 'c', 500, 200); // id=3, created_at=200之前被点过复制
ins.run('text', 'd', null, 200); // id=4, created_at=200
ins.run('text', 'e', 900, 100); // id=5, created_at=100之前被点过复制
ins.run('text', 'f', null, 400); // id=6, created_at=400
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// created_at DESCf(400), b(300), d(200), c(200), e(100), a(100)
// created_at 相同的 (d/c=200, e/a=100) 之间用 id DESC 决出次序:
// - 同为 200d(id=4) > c(id=3) → d 在前
// - 同为 100e(id=5) > a(id=1) → e 在前
assert.deepStrictEqual(got, ['f', 'b', 'd', 'c', 'e', 'a']);
db.close();
});
check('点过复制的项也排到顶movetop 把 created_at 同步更新到 now', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100);
ins.run('text', 'b', null, 200);
ins.run('text', 'c', null, 300);
// 模拟点过复制 c —— movetop 把 top_at 和 created_at 都更新到 now
db.prepare('UPDATE clips SET top_at = ?, created_at = ? WHERE id = (SELECT id FROM clips WHERE text = ?)').run(1000, 1000, 'a');
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// a(1000) > c(300) > b(200)
assert.deepStrictEqual(got, ['a', 'c', 'b']);
db.close();
});
check('外部新复制总会超过任何会话内置顶项(契约)', () => {
// 这个 case 就是用户报的 bug会话内点了 c 的复制 → c: top_at=500, created_at=200
// 接着又从外面复制了 f → f: top_at=NULL, created_at=400
// 老排序 (top_at DESC) 会把 c 排到 f 前面,错。
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'c', 500, 200);
ins.run('text', 'f', null, 400);
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
assert.deepStrictEqual(got, ['f', 'c'], '最新复制 (f) 必须在被点过复制的 (c) 前面');
db.close();
});
check('全部 created_at 相同时按 id DESC 给出稳定次序', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100);
ins.run('text', 'b', null, 100);
ins.run('text', 'c', null, 100);
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// 同一 created_atid DESC → c, b, a
assert.deepStrictEqual(got, ['c', 'b', 'a']);
db.close();
});
check('1 万行随机数据:按 created_at DESC + id DESC 排序稳定', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 10000; i++) {
ins.run('text', 't' + i, i % 7 === 0 ? (i * 13) % 5000 : null, (i * 31) % 9999);
}
})();
// 不论 top_at 是什么值ORDER BY created_at DESC, id DESC 永远稳定
const rows = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id);
// 跑两次结果必须完全一致id DESC tiebreaker 起作用)
const again = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id);
assert.deepStrictEqual(rows, again, 'id DESC 兜底让排序可重复');
// 行数对上
assert.strictEqual(rows.length, 500);
db.close();
});
check('新 ORDER BY 能走 idx_clips_created_id 复合索引(不再全表排序)', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 2000; i++) ins.run('text', 't' + i, null, i);
})();
db.exec('ANALYZE');
const plan = db
.prepare(`EXPLAIN QUERY PLAN SELECT id, top_at, created_at FROM clips ${NEW} LIMIT 200`)
.all().map((r) => r.detail).join(' | ');
// 视 SELECT 的列能否被索引完全覆盖SQLite 会报 "USING INDEX" 或
// "USING COVERING INDEX" —— 两者都算走上了索引。
assert.match(plan, /USING (COVERING )?INDEX idx_clips_created_id/, `应走 idx_clips_created_id实际: ${plan}`);
assert.doesNotMatch(plan, /TEMP B-TREE/, `不应再有临时排序,实际: ${plan}`);
db.close();
});
check('带完整列的真实列表查询同样不会退回全表排序', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,image,preview,top_at,created_at) VALUES(?,?,?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 2000; i++) {
ins.run('text', 't' + i, null, 'p' + i, i % 7 === 0 ? (i * 13) % 5000 : null, i);
}
})();
const cols = 'id, type, text, preview, top_at, created_at, (image IS NOT NULL) AS has_image';
const detail = db
.prepare(`EXPLAIN QUERY PLAN SELECT ${cols} FROM clips ${NEW} LIMIT 200`)
.all().map((r) => r.detail).join(' | ');
assert.match(detail, /idx_clips_created_id/, `应走 idx_clips_created_id实际: ${detail}`);
assert.doesNotMatch(detail, /TEMP B-TREE/, `不应临时排序,实际: ${detail}`);
db.close();
});
check('main.js 的 orderBy 返回 created_at DESC, id DESC不再用 top_at 排序)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const orderByMatch = src.match(/const\s+orderBy\s*=\s*\([^)]*\)\s*=>\s*`([^`]+)`/);
assert.ok(orderByMatch, '找不到 orderBy 定义');
const expr = orderByMatch[1];
assert.ok(
/created_at\s+DESC/.test(expr),
`orderBy 必须按 created_at DESC 排序,实际: ${expr}`
);
assert.ok(
!/top_at\s+DESC/.test(expr),
`orderBy 不应再用 top_at 排序,实际: ${expr}`
);
});
check('main.js 不再残留 (top_at IS NULL) 排序表达式', () => {
// 早期版本写过 `(top_at IS NULL), top_at DESC, created_at DESC`
// 那个 (top_at IS NULL) 是表达式索引satisfy不了会强制全表排序。
// 现在新排序不用 top_at 了,这个坑不应该再出现。
const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const orderByLines = src.split('\n').filter((l) => /ORDER BY|orderBy =/.test(l));
for (const line of orderByLines) {
assert.ok(
!/top_at\s+IS\s+NULL\s*\)\s*,/.test(line),
`排序表达式又回来了,会导致全表排序: ${line.trim()}`
);
}
});
console.log(`\n${passed} checks passed`);