mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-25 15:52:08 +00:00
## Summary Fixes #483 — navigating away from the live page while matrix/hop animations are running throws `TypeError: Cannot read properties of null (reading 'addLayer')`. ## Root Cause `destroy()` sets `animLayer = null` and `pathsLayer = null`, but in-flight `requestAnimationFrame` callbacks continue executing and attempt to call `.addTo(animLayer)` or `.removeLayer()` on the now-null references. The entry guards at the top of `drawMatrixLine()` and `drawAnimatedLine()` only protect the initial call — not the rAF continuation loops inside `tick()`, `fadeOut()`, `animateLine()`, and `animateFade()`. ## Fix Added null-guards (`if (!animLayer || !pathsLayer) return`) at the top of all four rAF callback functions in `live.js`: 1. **`tick()`** (line ~2203) — matrix animation main loop 2. **`fadeOut()`** (line ~2253) — matrix animation fade-out 3. **`animateLine()`** (line ~2302) — standard line animation main loop 4. **`animateFade()`** (line ~2337) — standard line fade-out This pattern is already used elsewhere in the file (e.g., line 1873, 1886) for the same purpose. ## Testing - All unit tests pass (`npm test` — 0 failures) - Go server tests pass (`cmd/server` + `cmd/ingestor`) - Change is defensive only (early return on null) — no behavioral change when layers exist --------- Co-authored-by: you <you@example.com>
129 lines
5.6 KiB
JavaScript
129 lines
5.6 KiB
JavaScript
/* Unit tests for live.js animation system — verifies rAF migration and concurrency cap */
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
const src = fs.readFileSync('public/live.js', 'utf8');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
|
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
|
}
|
|
|
|
console.log('\n=== Animation interval elimination ===');
|
|
|
|
test('pulseNode does not use setInterval', () => {
|
|
// Extract pulseNode function body
|
|
const pulseStart = src.indexOf('function pulseNode(');
|
|
const nextFn = src.indexOf('\n function ', pulseStart + 1);
|
|
const body = src.substring(pulseStart, nextFn);
|
|
assert.ok(!body.includes('setInterval'), 'pulseNode still uses setInterval');
|
|
assert.ok(body.includes('requestAnimationFrame'), 'pulseNode should use requestAnimationFrame');
|
|
});
|
|
|
|
test('drawAnimatedLine does not use setInterval', () => {
|
|
const drawStart = src.indexOf('function drawAnimatedLine(');
|
|
const nextFn = src.indexOf('\n function ', drawStart + 1);
|
|
const body = src.substring(drawStart, nextFn);
|
|
assert.ok(!body.includes('setInterval'), 'drawAnimatedLine still uses setInterval');
|
|
assert.ok(body.includes('requestAnimationFrame'), 'drawAnimatedLine should use requestAnimationFrame');
|
|
});
|
|
|
|
test('ghost hop pulse does not use setInterval', () => {
|
|
// Ghost pulse is inside animatePath
|
|
const animStart = src.indexOf('function animatePath(');
|
|
const animEnd = src.indexOf('\n function ', animStart + 1);
|
|
const body = src.substring(animStart, animEnd);
|
|
assert.ok(!body.includes('setInterval'), 'animatePath still uses setInterval');
|
|
});
|
|
|
|
console.log('\n=== Concurrency cap ===');
|
|
|
|
test('MAX_CONCURRENT_ANIMS is defined', () => {
|
|
assert.ok(src.includes('MAX_CONCURRENT_ANIMS'), 'MAX_CONCURRENT_ANIMS constant not found');
|
|
});
|
|
|
|
test('MAX_CONCURRENT_ANIMS is set to 20', () => {
|
|
const match = src.match(/MAX_CONCURRENT_ANIMS\s*=\s*(\d+)/);
|
|
assert.ok(match, 'Could not parse MAX_CONCURRENT_ANIMS value');
|
|
assert.strictEqual(parseInt(match[1]), 20);
|
|
});
|
|
|
|
test('animatePath checks MAX_CONCURRENT_ANIMS before proceeding', () => {
|
|
const animStart = src.indexOf('function animatePath(');
|
|
// Check that within the first 200 chars of the function, we check the cap
|
|
const snippet = src.substring(animStart, animStart + 300);
|
|
assert.ok(snippet.includes('activeAnims >= MAX_CONCURRENT_ANIMS'), 'animatePath should check activeAnims against cap');
|
|
});
|
|
|
|
console.log('\n=== Safety: no stale setInterval in animation functions ===');
|
|
|
|
test('no setInterval remains in animation hot path', () => {
|
|
// The only acceptable setIntervals are the UI ones (timeline, clock, prune, rate counter)
|
|
// Count total setInterval occurrences
|
|
const matches = src.match(/setInterval\(/g) || [];
|
|
// Count known OK ones: _timelineRefreshInterval, _lcdClockInterval, _pruneInterval, _rateCounterInterval
|
|
const okPatterns = ['_timelineRefreshInterval', '_lcdClockInterval', '_pruneInterval', '_rateCounterInterval'];
|
|
let okCount = 0;
|
|
for (const p of okPatterns) {
|
|
if (src.includes(p + ' = setInterval') || src.includes(p + '= setInterval')) okCount++;
|
|
}
|
|
// Allow some non-animation setIntervals (the 4 UI ones above)
|
|
assert.ok(matches.length <= okCount + 1,
|
|
`Found ${matches.length} setInterval calls, expected at most ${okCount + 1} (non-animation). Some animation setIntervals may remain.`);
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed\n`);
|
|
if (failed > 0) process.exit(1);
|
|
|
|
/* === Null-guard coverage for rAF callbacks === */
|
|
const src2 = fs.readFileSync('public/live.js', 'utf8');
|
|
let p2 = 0, f2 = 0;
|
|
function test2(name, fn) {
|
|
try { fn(); p2++; console.log(` ✅ ${name}`); }
|
|
catch (e) { f2++; console.log(` ❌ ${name}: ${e.message}`); }
|
|
}
|
|
|
|
console.log('\n=== Null guards on rAF animation callbacks ===');
|
|
|
|
test2('animatePath tick() has null guard', () => {
|
|
// tick is inside animatePath, after "function tick(now)"
|
|
const tickStart = src2.indexOf('function tick(now)');
|
|
const tickBody = src2.substring(tickStart, tickStart + 200);
|
|
assert.ok(tickBody.includes('!animLayer || !pathsLayer'), 'tick() missing animLayer/pathsLayer null guard');
|
|
});
|
|
|
|
test2('animatePath fadeOut() has null guard', () => {
|
|
const fadeOutStart = src2.indexOf('function fadeOut(now)');
|
|
const fadeOutBody = src2.substring(fadeOutStart, fadeOutStart + 200);
|
|
assert.ok(fadeOutBody.includes('!animLayer || !pathsLayer'), 'fadeOut() missing animLayer/pathsLayer null guard');
|
|
});
|
|
|
|
test2('drawAnimatedLine animateLine() has null guard', () => {
|
|
const lineStart = src2.indexOf('function animateLine(now)');
|
|
const lineBody = src2.substring(lineStart, lineStart + 200);
|
|
assert.ok(lineBody.includes('!animLayer || !pathsLayer'), 'animateLine() missing animLayer/pathsLayer null guard');
|
|
});
|
|
|
|
test2('drawAnimatedLine animateFade() has null guard', () => {
|
|
const fadeStart = src2.indexOf('function animateFade(now)');
|
|
const fadeBody = src2.substring(fadeStart, fadeStart + 200);
|
|
assert.ok(fadeBody.includes('!pathsLayer'), 'animateFade() missing pathsLayer null guard');
|
|
});
|
|
|
|
test2('pulseNode animatePulse() has null guard', () => {
|
|
const pulseStart = src2.indexOf('function animatePulse(now)');
|
|
const pulseBody = src2.substring(pulseStart, pulseStart + 200);
|
|
assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard');
|
|
});
|
|
|
|
test2('ghostPulse has null guard', () => {
|
|
const ghostStart = src2.indexOf('function ghostPulse(now)');
|
|
const ghostBody = src2.substring(ghostStart, ghostStart + 200);
|
|
assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard');
|
|
});
|
|
|
|
console.log(`\n${p2} passed, ${f2} failed\n`);
|
|
if (f2 > 0) process.exit(1);
|