|
|
@@ -0,0 +1,96 @@
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import { createRegionLoadingController } from '../../src/modules/cockpit/services/regionLoading.js'
|
|
|
+
|
|
|
+function event() {
|
|
|
+ const listeners = new Set()
|
|
|
+ return {
|
|
|
+ addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn) },
|
|
|
+ raise(value) { for (const fn of listeners) fn(value) },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function harness({ count = 2, budget = 1 } = {}) {
|
|
|
+ let clock = 0, timer = null, renderRequested = false
|
|
|
+ const models = new Map(), removed = []
|
|
|
+ const regions = Array.from({ length: count }, (_, index) => ({
|
|
|
+ id: count - index, contentCount: 1, decodedGeometryBytes: 1024,
|
|
|
+ }))
|
|
|
+ const controller = createRegionLoadingController({
|
|
|
+ regions, showBudgetPerFrame: budget, maxConcurrentLoads: count,
|
|
|
+ load: async region => {
|
|
|
+ const model = { show: false, tilesLoaded: false, tileVisible: event() }
|
|
|
+ models.set(region.id, model)
|
|
|
+ return model
|
|
|
+ },
|
|
|
+ add() {}, remove(model) { removed.push(model) }, requestRender() { renderRequested = true },
|
|
|
+ distance: region => region.id * 10, readCameraKey: () => 'fixed', now: () => clock,
|
|
|
+ setTimer(callback) { timer = callback; return 1 }, clearTimer() { timer = null },
|
|
|
+ })
|
|
|
+ controller.refresh()
|
|
|
+ await Promise.resolve()
|
|
|
+ for (const model of models.values()) model.tilesLoaded = true
|
|
|
+ return {
|
|
|
+ controller, models, removed,
|
|
|
+ shown: () => [...models].filter(([, model]) => model.show).map(([id]) => id).sort((a, b) => a - b),
|
|
|
+ renderPending: () => renderRequested,
|
|
|
+ frame(at) { clock = at; renderRequested = false; controller.onRender() },
|
|
|
+ timer(at) { clock = at; const callback = timer; timer = null; callback?.() },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 199ms postRender + 200ms refresh occur before the next 216ms render.
|
|
|
+// A scheduling callback is not a rendered frame, even after a long idle gap.
|
|
|
+for (const budget of [1, 2]) {
|
|
|
+ const h = await harness({ count: budget * 2, budget })
|
|
|
+ try {
|
|
|
+ const nearest = Array.from({ length: budget }, (_, index) => index + 1)
|
|
|
+ h.frame(199)
|
|
|
+ assert.deepEqual(h.shown(), nearest, 'First-show slots go to nearest ready regions')
|
|
|
+ assert.equal(h.renderPending(), true, 'First show requests the next render to advance deferred work')
|
|
|
+ h.timer(200)
|
|
|
+ assert.deepEqual(h.shown(), nearest, 'Timer must share the current frame show budget')
|
|
|
+ h.timer(400)
|
|
|
+ h.controller.refresh()
|
|
|
+ assert.deepEqual(h.shown(), nearest, 'Elapsed time and explicit refresh do not grant frame slots')
|
|
|
+ for (let id = budget + 1; id <= budget * 2; id++) {
|
|
|
+ assert.equal(h.controller.detailReady(id), false, 'Deferred regions retain coarse fallback')
|
|
|
+ }
|
|
|
+ // A new frame, not wall-clock advancement, grants the next budget.
|
|
|
+ h.frame(400)
|
|
|
+ assert.equal(h.shown().length, budget * 2, 'Ready work progresses on the next frame')
|
|
|
+ } finally { h.controller.detach() }
|
|
|
+}
|
|
|
+
|
|
|
+// A newly shown model must actually render before its coarse fallback yields.
|
|
|
+{
|
|
|
+ const h = await harness()
|
|
|
+ try {
|
|
|
+ h.frame(199)
|
|
|
+ h.timer(200)
|
|
|
+ assert.equal(h.controller.detailReady(1), false)
|
|
|
+ h.models.get(1).tileVisible.raise({ content: { url: 'near', trianglesLength: 1 } })
|
|
|
+ h.frame(216)
|
|
|
+ assert.equal(h.controller.detailReady(1), true)
|
|
|
+ assert.equal(h.controller.detailReady(2), false)
|
|
|
+ h.models.get(2).tileVisible.raise({ content: { url: 'far', trianglesLength: 1 } })
|
|
|
+ h.frame(233)
|
|
|
+ assert.equal(h.controller.detailReady(2), true)
|
|
|
+ } finally { h.controller.detach() }
|
|
|
+}
|
|
|
+
|
|
|
+// Exhausted show slots must not throttle pipeline release or resurrect disposed work.
|
|
|
+{
|
|
|
+ const h = await harness()
|
|
|
+ h.frame(199)
|
|
|
+ h.controller.setPipeline(true)
|
|
|
+ assert.equal(h.removed.length, 2, 'Pipeline change releases every loaded model immediately')
|
|
|
+ assert.equal(h.controller.detailReady(1), false)
|
|
|
+ assert.equal(h.controller.detailReady(2), false)
|
|
|
+ h.controller.detach()
|
|
|
+ h.timer(400)
|
|
|
+ h.frame(416)
|
|
|
+ h.controller.refresh()
|
|
|
+ assert.equal(h.models.size, 2, 'Disposed controller does not start another model')
|
|
|
+ assert.equal(h.removed.length, 2, 'Each model is removed exactly once')
|
|
|
+}
|
|
|
+console.log('PASS region frame budget: timer/render interleaving, nearest-first, progress, coarse handoff, disposal')
|