verify-frame-motion-window.mjs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import { createMotionIntervalWindow } from '../../src/modules/cockpit/domain/frameMotionWindow.js'
  4. const referenceStats = list => {
  5. const sorted = [...list].sort((a, b) => a - b)
  6. return {
  7. samples: sorted.length,
  8. p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null,
  9. p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null,
  10. maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null,
  11. over50Ms: sorted.filter(n => n > 50).length,
  12. }
  13. }
  14. const emptyStats = { samples: 0, p50Ms: null, p95Ms: null, maxMs: null, over50Ms: 0 }
  15. // Continuous motion keeps the same percentile math the export already had.
  16. {
  17. const w = createMotionIntervalWindow()
  18. assert.equal(w.push(0, true), 'first', 'The first frame only seeds the previous frame')
  19. const frames = [16.7, 16.6, 33.4, 17.1, 16.9]
  20. let t = 0
  21. for (const dt of frames) assert.equal(w.push(t += dt, true), 'sampled')
  22. const snapshot = w.snapshot()
  23. const { samples: count, p50Ms, p95Ms, maxMs, over50Ms } = snapshot
  24. assert.deepEqual({ samples: count, p50Ms, p95Ms, maxMs, over50Ms }, referenceStats(frames))
  25. assert.equal(snapshot.restartGapsExcluded, 0)
  26. assert.equal(snapshot.longGapsExcluded, 0)
  27. assert.equal(snapshot.maxExcludedMs, null)
  28. }
  29. // Idle -> motion start must not enter the window, and the loss is counted.
  30. {
  31. const w = createMotionIntervalWindow()
  32. w.push(1000, false)
  33. assert.equal(w.push(1600, true), 'restart-gap', 'First moving frame after idle is a restart gap')
  34. assert.equal(w.push(1616.7, true), 'sampled', 'The next continuous moving frame is sampled')
  35. const snapshot = w.snapshot()
  36. assert.equal(snapshot.samples, 1)
  37. assert.equal(snapshot.maxMs, 16.7)
  38. assert.equal(snapshot.restartGapsExcluded, 1)
  39. assert.equal(snapshot.maxExcludedMs, 600, 'Excluded gap magnitude stays visible')
  40. }
  41. // Hidden pages mark a break instead of silently treating the gap as motion.
  42. {
  43. const w = createMotionIntervalWindow()
  44. w.push(500, true)
  45. w.markGap(900)
  46. assert.equal(w.push(940, true), 'restart-gap')
  47. assert.equal(w.snapshot().samples, 0)
  48. assert.equal(w.snapshot().restartGapsExcluded, 1)
  49. }
  50. // A genuine >=1000ms stall between two moving frames is excluded but reported.
  51. {
  52. const w = createMotionIntervalWindow()
  53. w.push(0, true)
  54. assert.equal(w.push(999, true), 'sampled', 'Just under the ceiling is still a real interval')
  55. assert.equal(w.push(1999, true), 'long-gap', 'Exactly 1000ms is a gap, not a frame interval')
  56. assert.equal(w.push(2999, true), 'long-gap')
  57. const snapshot = w.snapshot()
  58. assert.equal(snapshot.samples, 1)
  59. assert.equal(snapshot.longGapsExcluded, 2)
  60. assert.equal(snapshot.maxExcludedMs, 1000)
  61. }
  62. // Slow but continuous frames are real evidence and must not be hidden.
  63. {
  64. const w = createMotionIntervalWindow()
  65. w.push(0, true)
  66. assert.equal(w.push(300, true), 'sampled')
  67. w.push(600, true)
  68. assert.equal(w.snapshot().maxMs, 300)
  69. assert.equal(w.snapshot().over50Ms, 2)
  70. }
  71. // Non-monotonic or stationary observations never create samples.
  72. {
  73. const w = createMotionIntervalWindow()
  74. w.push(100, true)
  75. assert.equal(w.push(100, true), 'idle', 'Zero-length interval is not a frame')
  76. assert.equal(w.push(90, true), 'idle', 'Backwards clock is rejected')
  77. assert.equal(w.snapshot().samples, 0)
  78. assert.deepEqual(w.snapshot(), { ...emptyStats, restartGapsExcluded: 0, longGapsExcluded: 0, maxExcludedMs: null })
  79. }
  80. // The rolling window keeps the newest 240 intervals.
  81. {
  82. const w = createMotionIntervalWindow()
  83. w.push(0, true)
  84. for (let i = 1; i <= 300; i++) w.push(i * 10, true)
  85. const snapshot = w.snapshot()
  86. assert.equal(snapshot.samples, 240, 'Only the newest 240 intervals are kept')
  87. assert.equal(snapshot.maxMs, 10, 'Every kept interval is the same 10ms step')
  88. assert.equal(snapshot.p50Ms, 10)
  89. }
  90. // Reset clears samples and counters so a new pilot window starts clean.
  91. {
  92. const w = createMotionIntervalWindow()
  93. w.push(0, true); w.push(2000, true); w.markGap(2100)
  94. w.reset()
  95. assert.deepEqual(w.snapshot(), { ...emptyStats, restartGapsExcluded: 0, longGapsExcluded: 0, maxExcludedMs: null })
  96. assert.equal(w.push(5000, true), 'first', 'Reset also clears the previous frame')
  97. }
  98. // Wiring: the export must use this window instead of the old raw interval list.
  99. {
  100. const source = readFileSync('src/modules/cockpit/services/modelDiagnostics.js', 'utf8')
  101. assert.match(source, /createMotionIntervalWindow/, 'diagnostics must use the motion window')
  102. assert.match(source, /motion\.push\(/, 'diagnostics must feed the window')
  103. assert.match(source, /motion\.markGap\(/, 'hidden frames must mark a break')
  104. assert.doesNotMatch(source, /add\(intervals/, 'the raw interval list must be gone')
  105. }
  106. console.log('PASS motion interval window: continuity, restart/hidden gaps, stall reporting, rolling cap, reset, wiring')