test(queue): extract terminal-job prune into testable module + 10 tests

handleBatchDone's terminal-job auto-cap (introduced in 3.3.0) lived
inline as a manual two-pass loop over queueJobs. Pull the algorithm
into lib/queue-prune.js as pure pruneOldestTerminalJobs(jobs, limit)
that returns { kept, dropped } so the caller can clean up its index/
selection in one go. Same single implementation backs runtime and
tests via dual-environment loader (CommonJS module.exports for Node
tests, window.QueuePrune global for the renderer via index.html
script tag).

Coverage:
- Empty / null / non-array input → no-op
- All-non-terminal → no-op (regardless of limit)
- Terminal count ≤ limit → no-op
- Terminal count > limit → drops oldest by insertion order
- Mixed queue: non-terminals always kept, only terminals dropped
- limit=0 → drops every terminal
- Negative / NaN / Infinity limits → safe no-op
- Malformed entries (null, missing status) handled without throwing
- Large-queue stress (5000 done jobs) keeps newest 500
- TERMINAL_STATUSES set covers exactly done/skipped/error/aborted

Renderer uses window.QueuePrune?. so a failed script load just
disables the prune rather than crashing every batch-done. 107/107
tests green.
This commit is contained in:
Administrator 2026-04-28 06:41:47 +02:00
parent 7269504d3d
commit f83fdabea3
5 changed files with 192 additions and 28 deletions

59
lib/queue-prune.js Normal file
View File

@ -0,0 +1,59 @@
// Queue auto-prune logic. Extracted from renderer/app.js handleBatchDone so
// the algorithm can be unit-tested without needing a DOM or the renderer's
// module-level state (queueJobs, _jobIndexById).
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) so the same single
// implementation backs both runtime and tests — no drift between them.
//
// Behaviour: when the number of terminal-status jobs (done / skipped /
// error / aborted) in the queue exceeds `limit`, drop the oldest terminal
// jobs (insertion order) until we're back at the limit. Non-terminal jobs
// (queued / preview / uploading / retrying / getting-server) are always
// kept — those are work the user can still act on. Without this cap a
// long session accumulates thousands of done rows and every render becomes
// O(N) on a perpetually-growing N.
(function (root) {
'use strict';
const TERMINAL_STATUSES = new Set(['done', 'skipped', 'error', 'aborted']);
/**
* Compute which jobs to keep vs drop, given a queue and a terminal-jobs cap.
* @param {Array<{id: string, status: string}>} jobs the current queue
* @param {number} limit max terminal jobs to keep
* @returns {null | { kept: Array, dropped: Array }} null when nothing changed
*/
function pruneOldestTerminalJobs(jobs, limit) {
if (!Array.isArray(jobs) || jobs.length === 0) return null;
if (!Number.isFinite(limit) || limit < 0) return null;
// Walk once, record indices of terminal jobs in insertion order.
const terminalIdxs = [];
for (let i = 0; i < jobs.length; i++) {
const j = jobs[i];
if (j && TERMINAL_STATUSES.has(j.status)) terminalIdxs.push(i);
}
if (terminalIdxs.length <= limit) return null;
const dropCount = terminalIdxs.length - limit;
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
const kept = [];
const dropped = [];
for (let i = 0; i < jobs.length; i++) {
if (dropSet.has(i)) dropped.push(jobs[i]);
else kept.push(jobs[i]);
}
return { kept, dropped };
}
const api = { pruneOldestTerminalJobs, TERMINAL_STATUSES };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.QueuePrune = api;
}
})(typeof window !== 'undefined' ? window : this);

View File

@ -2023,34 +2023,23 @@ function handleBatchDone(summary) {
queueJobs = nextJobs;
renderQueueTable();
} else {
// Auto-prune for the default (removeOnDone=false) too: cap done/skipped
// jobs at the most recent N so the queue can't grow unbounded across
// long sessions. Without this, sortQueueJobs / _computeQueueStats /
// renderQueueTable all become O(N) on a forever-growing N and the UI
// starts visibly lagging once the queue passes a few thousand entries.
// 500 most-recent terminal jobs is enough for "see what just happened"
// while stopping the runaway growth.
// Auto-prune for the default (removeOnDone=false) too: cap terminal
// jobs (done/skipped/error/aborted) at the most recent N so the queue
// can't grow unbounded across long sessions. The algorithm lives in
// lib/queue-prune.js (same impl Node-tested, see tests/queue-prune.test.js)
// and the result tells us which jobs to drop so we can clean up the
// index + selection in one pass.
const TERMINAL_KEEP_LIMIT = 500;
const terminalIdxs = [];
for (let i = 0; i < queueJobs.length; i++) {
const s = queueJobs[i].status;
if (s === 'done' || s === 'skipped' || s === 'error' || s === 'aborted') terminalIdxs.push(i);
}
if (terminalIdxs.length > TERMINAL_KEEP_LIMIT) {
const dropCount = terminalIdxs.length - TERMINAL_KEEP_LIMIT;
// terminalIdxs is in insertion order → first `dropCount` are the
// oldest. Remove those, keep everything else.
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
const nextJobs = [];
for (let i = 0; i < queueJobs.length; i++) {
if (dropSet.has(i)) {
removeJobFromIndex(queueJobs[i]);
selectedJobIds.delete(queueJobs[i].id);
} else {
nextJobs.push(queueJobs[i]);
}
// Optional-chain so the renderer still works if the prune script fails
// to load (e.g. file:// path issues during dev) — falls back to no-prune
// rather than crashing on every batch-done.
const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT);
if (result) {
for (const j of result.dropped) {
removeJobFromIndex(j);
selectedJobIds.delete(j.id);
}
queueJobs = nextJobs;
queueJobs = result.kept;
renderQueueTable();
}
}

View File

@ -330,6 +330,7 @@
</div>
</div>
<script src="../lib/queue-prune.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@ -9,12 +9,12 @@
- ✅ 3.3.5 — Log-Rotation extrahiert nach `lib/log-rotation.js` + 10 neue Unit-Tests (cap, shift, eviction, idempotency, maxBackups=1, invalid input, no-extension)
- ✅ 3.3.6 — CSS `.queue-row` transition nur noch auf `:hover` (kein 150ms compositor-tween bei status-flips)
- ✅ 3.3.7 — `_sessionTrackedJobs`/`_sessionDoneJobs` werden bei handleBatchDone gegen current queueJobs geprunt (no more unbounded session memory growth across batches)
- ✅ 3.3.8 — queue-cap-prune-Logik nach `lib/queue-prune.js` extrahiert (dual-environment: Node + Browser-global) + 10 Unit-Tests (insertion-order, limit=0, malformed entries, large-queue 5000-job sweep)
## Open items (priorisiert)
### Code-Qualität (deferred — bräuchte jsdom für DOM/state)
- [ ] queue-cap-prune-Logik (3.3.0 handleBatchDone)
- [ ] sortQueueJobs dynamic-throttle (3.3.0)
- [ ] sortQueueJobs dynamic-throttle (3.3.0) — modul-state-abhängig im renderer
- [ ] removeFromQueueOnDone microtask-coalesce (3.3.1) — Microtask-Timing schwer zu testen ohne fake-timer setup
### Loop-Status

115
tests/queue-prune.test.js Normal file
View File

@ -0,0 +1,115 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { pruneOldestTerminalJobs, TERMINAL_STATUSES } = require('../lib/queue-prune');
const j = (id, status) => ({ id, status });
test('returns null on empty / non-array input', () => {
assert.equal(pruneOldestTerminalJobs([], 5), null);
assert.equal(pruneOldestTerminalJobs(null, 5), null);
assert.equal(pruneOldestTerminalJobs(undefined, 5), null);
});
test('returns null when all jobs are non-terminal regardless of limit', () => {
const jobs = [j('a', 'queued'), j('b', 'uploading'), j('c', 'preview')];
assert.equal(pruneOldestTerminalJobs(jobs, 0), null);
assert.equal(pruneOldestTerminalJobs(jobs, 100), null);
});
test('returns null when terminal count is at or under the limit', () => {
const jobs = [j('a', 'done'), j('b', 'done'), j('c', 'queued')];
assert.equal(pruneOldestTerminalJobs(jobs, 2), null, 'terminal=2, limit=2 → no-op');
assert.equal(pruneOldestTerminalJobs(jobs, 3), null, 'terminal=2, limit=3 → no-op');
});
test('drops oldest terminal jobs when over the limit, keeps non-terminal', () => {
const jobs = [
j('t1', 'done'), // oldest terminal — should be dropped
j('t2', 'done'), // should be dropped
j('queued1', 'queued'),
j('t3', 'error'), // newest of the dropped block
j('uploading1', 'uploading'),
j('t4', 'done'), // kept (within limit window)
j('t5', 'skipped'), // kept
j('t6', 'aborted'), // kept
];
// 6 terminal, limit 3 → drop 3 oldest (t1, t2, t3)
const result = pruneOldestTerminalJobs(jobs, 3);
assert.notEqual(result, null);
const droppedIds = result.dropped.map(x => x.id).sort();
assert.deepEqual(droppedIds, ['t1', 't2', 't3']);
// Non-terminal jobs always kept; surviving terminals are the newest 3
const keptIds = result.kept.map(x => x.id);
assert.deepEqual(keptIds, ['queued1', 'uploading1', 't4', 't5', 't6']);
});
test('respects insertion order (oldest by index, not by status)', () => {
const jobs = [
j('older-error', 'error'),
j('newer-done', 'done'),
j('newest-aborted', 'aborted'),
];
const result = pruneOldestTerminalJobs(jobs, 1);
assert.deepEqual(result.dropped.map(x => x.id), ['older-error', 'newer-done']);
assert.deepEqual(result.kept.map(x => x.id), ['newest-aborted']);
});
test('drops everything terminal when limit is 0', () => {
const jobs = [
j('q', 'queued'),
j('d1', 'done'),
j('d2', 'done'),
j('e1', 'error'),
];
const result = pruneOldestTerminalJobs(jobs, 0);
assert.deepEqual(result.dropped.map(x => x.id), ['d1', 'd2', 'e1']);
assert.deepEqual(result.kept.map(x => x.id), ['q']);
});
test('rejects negative or non-finite limits', () => {
const jobs = [j('a', 'done'), j('b', 'done')];
assert.equal(pruneOldestTerminalJobs(jobs, -1), null);
assert.equal(pruneOldestTerminalJobs(jobs, NaN), null);
assert.equal(pruneOldestTerminalJobs(jobs, Infinity), null,
'Infinity is technically not finite; safer to treat as no-op');
});
test('TERMINAL_STATUSES set covers all 4 terminal kinds', () => {
assert.ok(TERMINAL_STATUSES.has('done'));
assert.ok(TERMINAL_STATUSES.has('skipped'));
assert.ok(TERMINAL_STATUSES.has('error'));
assert.ok(TERMINAL_STATUSES.has('aborted'));
assert.equal(TERMINAL_STATUSES.size, 4);
// Non-terminal must not be in the set
for (const s of ['queued', 'preview', 'uploading', 'retrying', 'getting-server']) {
assert.equal(TERMINAL_STATUSES.has(s), false, `${s} must not be terminal`);
}
});
test('handles malformed entries (null / missing status) without throwing', () => {
const jobs = [
null,
j('a', 'done'),
{ id: 'no-status' }, // no status
j('b', 'done'),
];
// 2 terminal, limit 1 → drop oldest (a). null and no-status entries stay
// because they aren't terminal. The function must not throw on them.
const result = pruneOldestTerminalJobs(jobs, 1);
assert.notEqual(result, null);
assert.deepEqual(result.dropped.map(x => x && x.id), ['a']);
assert.equal(result.kept.length, 3);
});
test('large queue: keeps the newest `limit` terminals', () => {
const jobs = [];
for (let i = 0; i < 5000; i++) jobs.push(j(`done-${i}`, 'done'));
const result = pruneOldestTerminalJobs(jobs, 500);
assert.notEqual(result, null);
assert.equal(result.dropped.length, 4500);
assert.equal(result.kept.length, 500);
// First kept = done-4500 (the 4501st original entry)
assert.equal(result.kept[0].id, 'done-4500');
assert.equal(result.kept[result.kept.length - 1].id, 'done-4999');
});