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:
parent
8965983e0c
commit
cf34353036
51
lib/throttled-cache.js
Normal file
51
lib/throttled-cache.js
Normal 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);
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
113
tests/throttled-cache.test.js
Normal file
113
tests/throttled-cache.test.js
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user