|
|
@@ -0,0 +1,116 @@
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import { readFileSync } from 'node:fs'
|
|
|
+import { createMotionIntervalWindow } from '../../src/modules/cockpit/domain/frameMotionWindow.js'
|
|
|
+
|
|
|
+const referenceStats = list => {
|
|
|
+ const sorted = [...list].sort((a, b) => a - b)
|
|
|
+ return {
|
|
|
+ samples: sorted.length,
|
|
|
+ p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null,
|
|
|
+ p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null,
|
|
|
+ maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null,
|
|
|
+ over50Ms: sorted.filter(n => n > 50).length,
|
|
|
+ }
|
|
|
+}
|
|
|
+const emptyStats = { samples: 0, p50Ms: null, p95Ms: null, maxMs: null, over50Ms: 0 }
|
|
|
+
|
|
|
+// Continuous motion keeps the same percentile math the export already had.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ assert.equal(w.push(0, true), 'first', 'The first frame only seeds the previous frame')
|
|
|
+ const frames = [16.7, 16.6, 33.4, 17.1, 16.9]
|
|
|
+ let t = 0
|
|
|
+ for (const dt of frames) assert.equal(w.push(t += dt, true), 'sampled')
|
|
|
+ const snapshot = w.snapshot()
|
|
|
+ const { samples: count, p50Ms, p95Ms, maxMs, over50Ms } = snapshot
|
|
|
+ assert.deepEqual({ samples: count, p50Ms, p95Ms, maxMs, over50Ms }, referenceStats(frames))
|
|
|
+ assert.equal(snapshot.restartGapsExcluded, 0)
|
|
|
+ assert.equal(snapshot.longGapsExcluded, 0)
|
|
|
+ assert.equal(snapshot.maxExcludedMs, null)
|
|
|
+}
|
|
|
+
|
|
|
+// Idle -> motion start must not enter the window, and the loss is counted.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ w.push(1000, false)
|
|
|
+ assert.equal(w.push(1600, true), 'restart-gap', 'First moving frame after idle is a restart gap')
|
|
|
+ assert.equal(w.push(1616.7, true), 'sampled', 'The next continuous moving frame is sampled')
|
|
|
+ const snapshot = w.snapshot()
|
|
|
+ assert.equal(snapshot.samples, 1)
|
|
|
+ assert.equal(snapshot.maxMs, 16.7)
|
|
|
+ assert.equal(snapshot.restartGapsExcluded, 1)
|
|
|
+ assert.equal(snapshot.maxExcludedMs, 600, 'Excluded gap magnitude stays visible')
|
|
|
+}
|
|
|
+
|
|
|
+// Hidden pages mark a break instead of silently treating the gap as motion.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ w.push(500, true)
|
|
|
+ w.markGap(900)
|
|
|
+ assert.equal(w.push(940, true), 'restart-gap')
|
|
|
+ assert.equal(w.snapshot().samples, 0)
|
|
|
+ assert.equal(w.snapshot().restartGapsExcluded, 1)
|
|
|
+}
|
|
|
+
|
|
|
+// A genuine >=1000ms stall between two moving frames is excluded but reported.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ w.push(0, true)
|
|
|
+ assert.equal(w.push(999, true), 'sampled', 'Just under the ceiling is still a real interval')
|
|
|
+ assert.equal(w.push(1999, true), 'long-gap', 'Exactly 1000ms is a gap, not a frame interval')
|
|
|
+ assert.equal(w.push(2999, true), 'long-gap')
|
|
|
+ const snapshot = w.snapshot()
|
|
|
+ assert.equal(snapshot.samples, 1)
|
|
|
+ assert.equal(snapshot.longGapsExcluded, 2)
|
|
|
+ assert.equal(snapshot.maxExcludedMs, 1000)
|
|
|
+}
|
|
|
+
|
|
|
+// Slow but continuous frames are real evidence and must not be hidden.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ w.push(0, true)
|
|
|
+ assert.equal(w.push(300, true), 'sampled')
|
|
|
+ w.push(600, true)
|
|
|
+ assert.equal(w.snapshot().maxMs, 300)
|
|
|
+ assert.equal(w.snapshot().over50Ms, 2)
|
|
|
+}
|
|
|
+
|
|
|
+// Non-monotonic or stationary observations never create samples.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ w.push(100, true)
|
|
|
+ assert.equal(w.push(100, true), 'idle', 'Zero-length interval is not a frame')
|
|
|
+ assert.equal(w.push(90, true), 'idle', 'Backwards clock is rejected')
|
|
|
+ assert.equal(w.snapshot().samples, 0)
|
|
|
+ assert.deepEqual(w.snapshot(), { ...emptyStats, restartGapsExcluded: 0, longGapsExcluded: 0, maxExcludedMs: null })
|
|
|
+}
|
|
|
+
|
|
|
+// The rolling window keeps the newest 240 intervals.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ w.push(0, true)
|
|
|
+ for (let i = 1; i <= 300; i++) w.push(i * 10, true)
|
|
|
+ const snapshot = w.snapshot()
|
|
|
+ assert.equal(snapshot.samples, 240, 'Only the newest 240 intervals are kept')
|
|
|
+ assert.equal(snapshot.maxMs, 10, 'Every kept interval is the same 10ms step')
|
|
|
+ assert.equal(snapshot.p50Ms, 10)
|
|
|
+}
|
|
|
+
|
|
|
+// Reset clears samples and counters so a new pilot window starts clean.
|
|
|
+{
|
|
|
+ const w = createMotionIntervalWindow()
|
|
|
+ w.push(0, true); w.push(2000, true); w.markGap(2100)
|
|
|
+ w.reset()
|
|
|
+ assert.deepEqual(w.snapshot(), { ...emptyStats, restartGapsExcluded: 0, longGapsExcluded: 0, maxExcludedMs: null })
|
|
|
+ assert.equal(w.push(5000, true), 'first', 'Reset also clears the previous frame')
|
|
|
+}
|
|
|
+
|
|
|
+// Wiring: the export must use this window instead of the old raw interval list.
|
|
|
+{
|
|
|
+ const source = readFileSync('src/modules/cockpit/services/modelDiagnostics.js', 'utf8')
|
|
|
+ assert.match(source, /createMotionIntervalWindow/, 'diagnostics must use the motion window')
|
|
|
+ assert.match(source, /motion\.push\(/, 'diagnostics must feed the window')
|
|
|
+ assert.match(source, /motion\.markGap\(/, 'hidden frames must mark a break')
|
|
|
+ assert.doesNotMatch(source, /add\(intervals/, 'the raw interval list must be gone')
|
|
|
+}
|
|
|
+console.log('PASS motion interval window: continuity, restart/hidden gaps, stall reporting, rolling cap, reset, wiring')
|