test(sort): extract throttled-cache utility + 12 unit tests

The dynamic-key sort throttle (3.3.0) used an inline ad-hoc cache
object with a Date.now() comparison. Pull it out into a clean
generic-purpose makeThrottledCache helper that takes the TTL and an
optional clock function so tests can drive time without sleeping.
Same dual-environment loader (CommonJS for tests, window global for
the renderer via index.html script tag) as queue-prune.

API: get(sig, input) / set(sig, input, value) / clear() / peek().
sig + input identity must both match for a hit. Inputs are compared
by reference (===), exactly what sortQueueJobs needs to invalidate
on a fresh queueJobs array (e.g. backup import).

Coverage:
- empty cache → undefined
- within TTL → cached value
- past TTL → miss (boundary at refreshMs)
- different signature → miss
- different input identity → miss (even with same content)
- overwrite refreshes timestamp
- clear empties everything
- peek reports age + signature for diagnostics
- invalid TTL throws (negative, NaN, non-number)
- TTL=0 means every call misses (immediate expiry)
- default clock works (Date.now)
- large arrays tracked by identity, not value

Renderer rewires _dynamicSortCache to the new helper with a fallback
no-op shim if window.ThrottledCache failed to load. 119/119 green.
This commit is contained in:
Administrator 2026-04-28 07:12:52 +02:00
parent 8965983e0c
commit cf34353036
5 changed files with 179 additions and 9 deletions

51
lib/throttled-cache.js Normal file
View File

@ -0,0 +1,51 @@
// Time-windowed memoization. Reuses a previously-computed value if the
// signature + input identity match AND the cached entry is younger than
// `refreshMs`. Used by the renderer's dynamic-key sort throttle (every
// progress tick re-sorts a 5000-row queue → reuse for 200 ms, the user
// can't perceive sub-200 ms reorder lag).
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) — same single implementation
// across runtime and tests.
(function (root) {
'use strict';
/**
* Build a throttled cache. The clock is injected so tests don't have to
* sleep pass `() => fakeClock.value` from tests.
*
* @param {number} refreshMs cache TTL in milliseconds
* @param {() => number} [now] clock source, defaults to Date.now
*/
function makeThrottledCache(refreshMs, now) {
if (!Number.isFinite(refreshMs) || refreshMs < 0) {
throw new TypeError('refreshMs must be a non-negative finite number');
}
const clock = typeof now === 'function' ? now : () => Date.now();
let entry = null;
return {
get(sig, input) {
if (!entry) return undefined;
if (entry.sig !== sig) return undefined;
if (entry.input !== input) return undefined;
if (clock() - entry.ts >= refreshMs) return undefined;
return entry.value;
},
set(sig, input, value) {
entry = { sig, input, value, ts: clock() };
return value;
},
clear() { entry = null; },
// Introspection (mainly for tests/debug). Returns null when empty.
peek() {
if (!entry) return null;
return { sig: entry.sig, ts: entry.ts, age: clock() - entry.ts };
}
};
}
const api = { makeThrottledCache };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.ThrottledCache = api;
})(typeof window !== 'undefined' ? window : this);

View File

@ -1167,8 +1167,11 @@ const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
// one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's // one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's
// slightly out of order — the user can't perceive sub-200ms reorder lag, and // slightly out of order — the user can't perceive sub-200ms reorder lag, and
// at 5000 queued jobs this is the difference between smooth and stuttering. // at 5000 queued jobs this is the difference between smooth and stuttering.
let _dynamicSortCache = { key: '', direction: '', jobsRef: null, result: null, ts: 0 }; // Uses lib/throttled-cache.js (see tests/throttled-cache.test.js).
const DYNAMIC_SORT_REFRESH_MS = 200; const DYNAMIC_SORT_REFRESH_MS = 200;
const _dynamicSortCache = window.ThrottledCache
? window.ThrottledCache.makeThrottledCache(DYNAMIC_SORT_REFRESH_MS)
: { get: () => undefined, set: (s, i, v) => v, clear: () => {} };
function sortQueueJobs(jobs) { function sortQueueJobs(jobs) {
const { key, direction } = queueSortState; const { key, direction } = queueSortState;
@ -1179,11 +1182,13 @@ function sortQueueJobs(jobs) {
return _queueSortCache.result; return _queueSortCache.result;
} }
// Dynamic-key throttle: same key+direction+array, sorted within the last // Dynamic-key throttle: same key+direction+array, sorted within the last
// 200ms → reuse. Cleared on user-initiated sort changes (different key). // 200ms → reuse. The cache is keyed by `key|direction` and uses the jobs
if (!canCache && _dynamicSortCache.jobsRef === jobs && // array identity as the input ref, so a fresh queueJobs (e.g. after
_dynamicSortCache.key === key && _dynamicSortCache.direction === direction && // backup import) misses correctly.
_dynamicSortCache.result && (Date.now() - _dynamicSortCache.ts) < DYNAMIC_SORT_REFRESH_MS) { if (!canCache) {
return _dynamicSortCache.result; const dynSig = `${key}|${direction}`;
const cached = _dynamicSortCache.get(dynSig, jobs);
if (cached) return cached;
} }
const sorted = jobs.slice().sort((a, b) => { const sorted = jobs.slice().sort((a, b) => {
@ -1197,7 +1202,7 @@ function sortQueueJobs(jobs) {
return cmp * factor; return cmp * factor;
}); });
if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs }; if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs };
else _dynamicSortCache = { key, direction, jobsRef: jobs, result: sorted, ts: Date.now() }; else _dynamicSortCache.set(`${key}|${direction}`, jobs, sorted);
return sorted; return sorted;
} }

View File

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

View File

@ -10,11 +10,11 @@
- ✅ 3.3.6 — CSS `.queue-row` transition nur noch auf `:hover` (kein 150ms compositor-tween bei status-flips) - ✅ 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.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) - ✅ 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)
- ✅ 3.3.9 — Throttled-Cache nach `lib/throttled-cache.js` extrahiert (von sortQueueJobs dynamic-throttle genutzt) + 12 Unit-Tests (TTL-Boundary, identity-tracking, fake-clock, peek/clear, refreshMs=0, large-input)
## Open items (priorisiert) ## Open items (priorisiert)
### Code-Qualität (deferred — bräuchte jsdom für DOM/state) ### Code-Qualität (deferred)
- [ ] 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 - [ ] removeFromQueueOnDone microtask-coalesce (3.3.1) — Microtask-Timing schwer zu testen ohne fake-timer setup
### Loop-Status ### Loop-Status

View File

@ -0,0 +1,113 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeThrottledCache } = require('../lib/throttled-cache');
function fakeClock(start = 0) {
let t = start;
const fn = () => t;
fn.advance = (ms) => { t += ms; };
fn.set = (ms) => { t = ms; };
return fn;
}
test('returns undefined when empty', () => {
const c = makeThrottledCache(100);
assert.equal(c.get('any', {}), undefined);
});
test('returns the set value within the window', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [1, 2, 3];
c.set('sig-a', input, 'value-1');
assert.equal(c.get('sig-a', input), 'value-1');
clock.advance(50);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 50/100 ms');
clock.advance(49);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 99/100 ms');
});
test('expires exactly at refreshMs boundary', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
c.set('s', {}, 'v');
clock.advance(100);
assert.equal(c.get('s', {}), undefined, '>= refreshMs is a miss');
});
test('miss on different signature', () => {
const c = makeThrottledCache(1000, fakeClock());
const input = {};
c.set('sig-a', input, 'v');
assert.equal(c.get('sig-b', input), undefined);
});
test('miss on different input identity even with same signature', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('sig-a', { a: 1 }, 'v');
// Different object identity — the cache compares by ===, not by contents
assert.equal(c.get('sig-a', { a: 1 }), undefined);
});
test('overwrite by re-setting same signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [];
c.set('s', input, 'old');
clock.advance(50);
c.set('s', input, 'new');
// The new entry has a fresh timestamp → still valid for another 100 ms
clock.advance(99);
assert.equal(c.get('s', input), 'new');
});
test('clear empties the cache', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('s', {}, 'v');
c.clear();
assert.equal(c.get('s', {}), undefined);
assert.equal(c.peek(), null);
});
test('peek reports age and signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(1000, clock);
c.set('mysig', {}, 'v');
clock.advance(42);
const p = c.peek();
assert.equal(p.sig, 'mysig');
assert.equal(p.age, 42);
assert.equal(p.ts, 0);
});
test('throws on invalid refreshMs', () => {
assert.throws(() => makeThrottledCache(-1));
assert.throws(() => makeThrottledCache(NaN));
assert.throws(() => makeThrottledCache('100'));
});
test('refreshMs=0 means every call misses', () => {
const clock = fakeClock();
const c = makeThrottledCache(0, clock);
const input = {};
c.set('s', input, 'v');
// Same tick: 0 - 0 = 0 → not less than refreshMs (0) → miss
assert.equal(c.get('s', input), undefined);
});
test('default clock is Date.now when none provided', () => {
const c = makeThrottledCache(10000);
const input = {}; // single ref — get and set must use the SAME identity
c.set('x', input, 'v');
assert.equal(c.get('x', input), 'v');
});
test('large input arrays are tracked by identity, not value', () => {
const c = makeThrottledCache(1000, fakeClock());
const arr1 = new Array(10000).fill(0);
const arr2 = new Array(10000).fill(0);
c.set('s', arr1, 'cached');
assert.equal(c.get('s', arr1), 'cached');
assert.equal(c.get('s', arr2), undefined, 'different array → miss');
});