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

186 lines
8.7 KiB
JavaScript
Raw Permalink 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';
/**
* 列表排序:验证 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`);