release: v2.0.6
Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog. Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering. Publish verified Windows artifacts and refreshed English documentation.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
||||
|
||||
function lcg(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
||||
}
|
||||
|
||||
function key(f, h) { return `${String(f).toLowerCase()}|${String(h).toLowerCase()}`; }
|
||||
|
||||
test('property: removed iff (done && key in log) OR (savedAt finite && key unambiguous && newest matching log ts >= floor(savedAt/1000)*1000)', () => {
|
||||
const rnd = lcg(0x9e3779b1);
|
||||
const statuses = ['preview', 'done', 'error', 'aborted', 'queued', 'skipped'];
|
||||
const hosters = ['voe.sx', 'byse.sx', 'doodstream.com'];
|
||||
const names = ['a.mkv', 'b.mp4', 'A.MKV', 'c.mov'];
|
||||
const folders = ['C:/A/', 'C:/B/', 'D:/down/'];
|
||||
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
||||
|
||||
for (let iter = 0; iter < 3000; iter++) {
|
||||
const useSavedAt = rnd() < 0.7;
|
||||
const savedAt = useSavedAt ? Math.floor(rnd() * 2_000_000_000_000) : undefined;
|
||||
|
||||
const jobs = [];
|
||||
const nJobs = 1 + Math.floor(rnd() * 6);
|
||||
for (let i = 0; i < nJobs; i++) {
|
||||
const name = pick(names);
|
||||
// Mix shared and distinct paths so ambiguous keys (same name+hoster,
|
||||
// different folder) actually occur and exercise the guard.
|
||||
jobs.push({ id: `j${i}`, fileName: name, hoster: pick(hosters), status: pick(statuses), file: `${pick(folders)}${name}` });
|
||||
}
|
||||
|
||||
const log = [];
|
||||
const nLog = Math.floor(rnd() * 5);
|
||||
for (let i = 0; i < nLog; i++) {
|
||||
const hasTs = rnd() < 0.8;
|
||||
log.push({ fileName: pick(names), hoster: pick(hosters), ts: hasTs ? Math.floor(rnd() * 2_000_000_000_000) : undefined });
|
||||
}
|
||||
|
||||
const logKeys = new Set();
|
||||
const maxTs = new Map();
|
||||
for (const e of log) {
|
||||
const k = key(e.fileName, e.hoster);
|
||||
logKeys.add(k);
|
||||
if (typeof e.ts === 'number' && isFinite(e.ts)) {
|
||||
const prev = maxTs.get(k);
|
||||
if (prev === undefined || e.ts > prev) maxTs.set(k, e.ts);
|
||||
}
|
||||
}
|
||||
const filesPerKey = new Map();
|
||||
for (const job of jobs) {
|
||||
const k = key(job.fileName, job.hoster);
|
||||
if (!filesPerKey.has(k)) filesPerKey.set(k, new Set());
|
||||
filesPerKey.get(k).add(job.file || '');
|
||||
}
|
||||
const floor = (typeof savedAt === 'number' && isFinite(savedAt)) ? Math.floor(savedAt / 1000) * 1000 : null;
|
||||
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
|
||||
assert.equal(kept.length + removed.length, jobs.length, `iter ${iter}: partition must cover every job exactly once`);
|
||||
const keptIds = new Set(kept.map(j => j.id));
|
||||
const removedIds = new Set(removed.map(j => j.id));
|
||||
assert.equal(keptIds.size + removedIds.size, jobs.length, `iter ${iter}: no job in both partitions`);
|
||||
|
||||
for (const job of jobs) {
|
||||
const k = key(job.fileName, job.hoster);
|
||||
const doneInLog = job.status === 'done' && logKeys.has(k);
|
||||
const unambiguous = filesPerKey.get(k).size <= 1;
|
||||
const afterSnap = floor !== null && unambiguous && maxTs.has(k) && maxTs.get(k) >= floor;
|
||||
const shouldRemove = doneInLog || afterSnap;
|
||||
assert.equal(removedIds.has(job.id), shouldRemove,
|
||||
`iter ${iter}: job ${job.id} (status=${job.status} key=${k} unambig=${unambiguous}) expected removed=${shouldRemove}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('property: a genuinely-pending job is NEVER lost to a same-basename sibling completing after the snapshot', () => {
|
||||
const rnd = lcg(0x1234abcd);
|
||||
for (let iter = 0; iter < 500; iter++) {
|
||||
const savedAt = 1_000_000_000_000 + Math.floor(rnd() * 1_000_000);
|
||||
// X completed after the snapshot (logged); Y is a DIFFERENT file, same
|
||||
// basename + hoster, genuinely pending. Y must survive.
|
||||
const jobs = [
|
||||
{ id: 'X', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/A/clip.mp4' },
|
||||
{ id: 'Y', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/B/clip.mp4' }
|
||||
];
|
||||
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: savedAt + 1000 + Math.floor(rnd() * 1000) }];
|
||||
const { kept } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.ok(kept.some(j => j.id === 'Y'), `iter ${iter}: pending Y must never be silently dropped`);
|
||||
}
|
||||
});
|
||||
|
||||
test('property: 2-arg legacy call NEVER removes a non-done job (the v3.3.80 canary, fuzzed)', () => {
|
||||
const rnd = lcg(0xdeadbeef);
|
||||
const statuses = ['preview', 'error', 'aborted', 'queued', 'skipped'];
|
||||
const hosters = ['voe.sx', 'byse.sx'];
|
||||
const names = ['a.mkv', 'b.mp4'];
|
||||
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
||||
|
||||
for (let iter = 0; iter < 1000; iter++) {
|
||||
const jobs = [];
|
||||
const nJobs = 1 + Math.floor(rnd() * 5);
|
||||
for (let i = 0; i < nJobs; i++) {
|
||||
jobs.push({ id: `j${i}`, fileName: pick(names), hoster: pick(hosters), status: pick(statuses), file: `C:/x/${i}` });
|
||||
}
|
||||
const log = [];
|
||||
const nLog = Math.floor(rnd() * 4);
|
||||
for (let i = 0; i < nLog; i++) {
|
||||
log.push({ fileName: pick(names), hoster: pick(hosters), ts: Math.floor(rnd() * 2_000_000_000_000) });
|
||||
}
|
||||
const { removed } = partitionRestoredJobsByLog(jobs, log);
|
||||
assert.ok(removed.every(j => j.status === 'done'), `iter ${iter}: legacy 2-arg call must never drop a non-done job`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user