From f83fdabea356a16e094a80b0e07b6261dc1902bc Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 28 Apr 2026 06:41:47 +0200 Subject: [PATCH] test(queue): extract terminal-job prune into testable module + 10 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/queue-prune.js | 59 +++++++++++++++++++ renderer/app.js | 41 +++++--------- renderer/index.html | 1 + tasks/todo.md | 4 +- tests/queue-prune.test.js | 115 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 lib/queue-prune.js create mode 100644 tests/queue-prune.test.js diff --git a/lib/queue-prune.js b/lib/queue-prune.js new file mode 100644 index 0000000..81352f4 --- /dev/null +++ b/lib/queue-prune.js @@ -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); diff --git a/renderer/app.js b/renderer/app.js index 634af35..7127eaa 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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(); } } diff --git a/renderer/index.html b/renderer/index.html index 0d1d425..a9a2ae9 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -330,6 +330,7 @@ + diff --git a/tasks/todo.md b/tasks/todo.md index 43d8b0d..478b7ea 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -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 diff --git a/tests/queue-prune.test.js b/tests/queue-prune.test.js new file mode 100644 index 0000000..0cf82e9 --- /dev/null +++ b/tests/queue-prune.test.js @@ -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'); +});