update
This commit is contained in:
186
test/order-by.test.js
Normal file
186
test/order-by.test.js
Normal 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 DESC:f(400), b(300), d(200), c(200), e(100), a(100)
|
||||
// created_at 相同的 (d/c=200, e/a=100) 之间用 id DESC 决出次序:
|
||||
// - 同为 200:d(id=4) > c(id=3) → d 在前
|
||||
// - 同为 100:e(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_at,id 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`);
|
||||
Reference in New Issue
Block a user