diff --git a/tests/test_stability_characterization.mjs b/tests/test_stability_characterization.mjs new file mode 100644 index 0000000..d84a6c1 --- /dev/null +++ b/tests/test_stability_characterization.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import {HistoryStack} from '../js/canvas/CanvasHistory.js'; +import {ImageReferenceManager} from '../js/persistence/ImageReferenceManager.js'; + +function createReferenceCanvas(layers = []) { + return { + layers, + canvasState: { + layersUndoStack: [], + layersRedoStack: [], + }, + imageCache: { + has: () => false, + delete: () => false, + }, + }; +} + +test('history stack remains bounded during a large edit sequence', () => { + const history = new HistoryStack({ + clone: value => ({ ...value }), + historyLimit: 64, + }); + + for (let index = 0; index < 5000; index += 1) { + assert.equal(history.push({ index }), true); + } + + assert.equal(history.undoStack.length, 64); + assert.equal(history.undoStack[0].index, 4936); + assert.equal(history.undoStack.at(-1).index, 4999); + assert.equal(history.redoStack.length, 0); +}); + +test('reference manager rebuilds unique ids across current and historical layers', () => { + const currentLayers = Array.from({ length: 2000 }, (_, index) => ({ + imageId: `current-${index % 100}`, + })); + const canvas = createReferenceCanvas(currentLayers); + canvas.canvasState.layersUndoStack = [[{ imageId: 'undo-only' }]]; + canvas.canvasState.layersRedoStack = [[{ imageId: 'redo-only' }]]; + + const manager = new ImageReferenceManager(canvas); + const usedImageIds = manager.collectAllUsedImageIds(); + + assert.equal(usedImageIds.size, 102); + assert.equal(usedImageIds.has('undo-only'), true); + assert.equal(usedImageIds.has('redo-only'), true); +}); + +test('reference manager cleanup stops its timer and releases tracked state', () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const originalClearInterval = Object.getOwnPropertyDescriptor(globalThis, 'clearInterval'); + const timers = []; + const clearedTimers = []; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + setInterval(callback, delay) { + const timer = { callback, delay }; + timers.push(timer); + return timer; + }, + clearInterval(timer) { + clearedTimers.push(timer); + }, + }, + }); + Object.defineProperty(globalThis, 'clearInterval', { + configurable: true, + value: timer => clearedTimers.push(timer), + }); + + try { + const manager = new ImageReferenceManager(createReferenceCanvas()); + manager.addReference('image-1'); + manager.addReference('image-1'); + manager.startGarbageCollection(); + + assert.equal(timers.length, 1); + assert.equal(manager.getStats().totalReferences, 2); + + manager.destroy(); + + assert.deepEqual(clearedTimers, [timers[0]]); + assert.deepEqual(manager.getStats(), { + trackedImages: 0, + totalReferences: 0, + isRunning: false, + gcInterval: 5 * 60 * 1000, + maxAge: 30 * 60 * 1000, + }); + } finally { + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete globalThis.window; + } + if (originalClearInterval) { + Object.defineProperty(globalThis, 'clearInterval', originalClearInterval); + } else { + delete globalThis.clearInterval; + } + } +}); + +test('garbage collection ignores reentrant calls while one run is active', async () => { + const manager = new ImageReferenceManager(createReferenceCanvas()); + let nestedRunCompleted = false; + + manager.findUnusedImages = async () => { + await manager.performGarbageCollection(); + nestedRunCompleted = true; + return []; + }; + + await manager.performGarbageCollection(); + + assert.equal(nestedRunCompleted, true); + assert.equal(manager.getStats().isRunning, false); +});