|
|
@@ -0,0 +1,149 @@
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import { createRegionLoadingController } from '../../src/modules/cockpit/services/regionLoading.js'
|
|
|
+
|
|
|
+// Replay the load boundary seen in the low-spec exports: coarse is ready,
|
|
|
+// close candidates remain visible, but the camera never stops moving.
|
|
|
+let clock = 0, pose = 'initial', visible = false
|
|
|
+const started = [], pending = new Map()
|
|
|
+const controller = createRegionLoadingController({
|
|
|
+ regions: [1, 2].map(id => ({ id, contentCount: 1, decodedGeometryBytes: 1024 })),
|
|
|
+ load: region => { started.push(region.id); return new Promise(resolve => pending.set(region.id, resolve)) },
|
|
|
+ add() {}, remove() {}, requestRender() {},
|
|
|
+ distance: region => visible ? region.id * 10 : Infinity,
|
|
|
+ canStart: () => true, readCameraKey: () => pose, now: () => clock,
|
|
|
+ setTimer: () => 1, clearTimer() {},
|
|
|
+})
|
|
|
+try {
|
|
|
+ controller.refresh()
|
|
|
+ visible = true
|
|
|
+ for (let i = 1; i <= 8; i++) {
|
|
|
+ clock = i * 180
|
|
|
+ pose = 'moving-' + i
|
|
|
+ controller.onRender()
|
|
|
+ }
|
|
|
+ assert.equal(controller.frontierSnapshot().moving, true)
|
|
|
+ const whileMoving = started.slice(), movingCount = controller.snapshot().length
|
|
|
+ clock += 1000
|
|
|
+ controller.refresh()
|
|
|
+ console.log(JSON.stringify({ whileMoving, afterSettling: started }))
|
|
|
+ assert.deepEqual(started, [1, 2], 'Same candidates start under the unchanged stationary concurrency limit')
|
|
|
+ assert.deepEqual(whileMoving, [1], 'Continuous movement must admit one nearest region, not starve all fine loading')
|
|
|
+ assert.equal(movingCount, 1, 'Movement keeps at most one initial load in flight')
|
|
|
+ console.log('PASS moving admission: visible near candidate progresses through one bounded load slot')
|
|
|
+} finally {
|
|
|
+ controller.detach()
|
|
|
+ for (const resolve of pending.values()) resolve({ show: false, tilesLoaded: false })
|
|
|
+ await Promise.resolve()
|
|
|
+}
|
|
|
+
|
|
|
+// Boundary cases use the same public controller and explicit asynchronous loads.
|
|
|
+const flush = async () => { await Promise.resolve(); await Promise.resolve() }
|
|
|
+async function check(options, scenario) {
|
|
|
+ const { count = 3, ...controllerOptions } = options
|
|
|
+ let clock = 0, pose = 0
|
|
|
+ const state = { visible: false, coarseReady: true }
|
|
|
+ const started = [], added = [], removed = [], models = new Map(), waits = new Map()
|
|
|
+ const controller = createRegionLoadingController({
|
|
|
+ regions: Array.from({ length: count }, (_, i) => ({ id: i + 1, contentCount: 1, decodedGeometryBytes: 1024 })),
|
|
|
+ distance: r => state.visible ? r.id * 10 : Infinity,
|
|
|
+ readCameraKey: () => String(pose), now: () => clock,
|
|
|
+ canStart: () => state.coarseReady,
|
|
|
+ coverageReady: e => e.instance.covered === true,
|
|
|
+ load: r => { started.push(r.id); return new Promise((resolve, reject) => waits.set(r.id, { resolve, reject })) },
|
|
|
+ add: m => added.push(m), remove: m => removed.push(m), requestRender() {},
|
|
|
+ setTimer: () => 1, clearTimer() {},
|
|
|
+ ...controllerOptions,
|
|
|
+ })
|
|
|
+ controller.refresh()
|
|
|
+ const h = {
|
|
|
+ controller, state, started, added, removed,
|
|
|
+ async step(ms = 180, moving = true) { clock += ms; if (moving) pose++; controller.onRender(); await flush() },
|
|
|
+ async resolveLoad(id, ready = false) {
|
|
|
+ const wait = waits.get(id)
|
|
|
+ assert.ok(wait, 'Only pending loads can resolve')
|
|
|
+ const m = { show: false, tilesLoaded: ready, covered: ready }
|
|
|
+ models.set(id, m); wait.resolve(m); waits.delete(id); await flush(); return m
|
|
|
+ },
|
|
|
+ markReady(id) { const m = models.get(id); m.tilesLoaded = m.covered = true },
|
|
|
+ async fail(id) { waits.get(id).reject(new Error('fixture load failed')); waits.delete(id); await flush() },
|
|
|
+ }
|
|
|
+ try { await scenario(h) } finally {
|
|
|
+ controller.detach()
|
|
|
+ for (const wait of waits.values()) wait.resolve({ show: false, tilesLoaded: false, covered: false })
|
|
|
+ await flush()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+await check({}, async h => {
|
|
|
+ h.state.visible = true
|
|
|
+ await h.step()
|
|
|
+ assert.deepEqual(h.started, [1], 'Nearest candidate starts first during movement')
|
|
|
+ await h.resolveLoad(1) // fromUrl resolved, but content is not ready yet.
|
|
|
+ await h.step()
|
|
|
+ assert.deepEqual(h.started, [1], 'Streaming keeps the single moving slot occupied')
|
|
|
+ assert.equal(h.controller.detailReady(1), false, 'Incomplete coverage keeps coarse fallback')
|
|
|
+ h.markReady(1)
|
|
|
+ await h.step()
|
|
|
+ assert.deepEqual(h.started, [1, 2], 'Arrival releases the slot without waiting for camera settle')
|
|
|
+})
|
|
|
+
|
|
|
+await check({}, async h => {
|
|
|
+ h.state.visible = true
|
|
|
+ await h.step(300, false)
|
|
|
+ assert.deepEqual(h.started, [1, 2], 'Stationary phase retains configured parallelism')
|
|
|
+ await h.step()
|
|
|
+ assert.equal(h.removed.length, 0, 'Moving does not cancel the two existing initial loads')
|
|
|
+ await h.resolveLoad(1, true); await h.step()
|
|
|
+ assert.deepEqual(h.started, [1, 2], 'Do not add a new load while one existing slot remains')
|
|
|
+ await h.resolveLoad(2, true); await h.step()
|
|
|
+ assert.deepEqual(h.started, [1, 2, 3], 'Resume bounded moving admission after existing work completes')
|
|
|
+})
|
|
|
+
|
|
|
+for (const budget of [{ maxContents: 1 }, { maxDecodedBytes: 1024 }]) {
|
|
|
+ await check({ budget }, async h => {
|
|
|
+ h.state.visible = true
|
|
|
+ await h.step(); await h.resolveLoad(1, true); await h.step()
|
|
|
+ await h.step(1000, false)
|
|
|
+ assert.deepEqual(h.started, [1], 'Neither movement nor settling bypasses the selection budget')
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+await check({ maxConcurrentLoads: 1 }, async h => {
|
|
|
+ h.state.visible = true
|
|
|
+ await h.step(300, false)
|
|
|
+ assert.deepEqual(h.started, [1], 'Explicit single-slot configuration still applies when stationary')
|
|
|
+ await h.resolveLoad(1, true); await h.step(300, false)
|
|
|
+ assert.deepEqual(h.started, [1, 2])
|
|
|
+})
|
|
|
+
|
|
|
+await check({}, async h => {
|
|
|
+ h.state.visible = true; h.state.coarseReady = false
|
|
|
+ await h.step(); await h.step()
|
|
|
+ assert.deepEqual(h.started, [], 'Coarse readiness gate remains mandatory')
|
|
|
+ h.state.coarseReady = true
|
|
|
+ await h.step(); assert.deepEqual(h.started, [1])
|
|
|
+ h.controller.setPipeline(true)
|
|
|
+ const m = await h.resolveLoad(1, true)
|
|
|
+ await h.step()
|
|
|
+ assert.equal(h.added.length, 0, 'Late completion cannot attach in pipeline mode')
|
|
|
+ assert.deepEqual(h.removed, [m], 'Rejected late model is released exactly once')
|
|
|
+ assert.deepEqual(h.started, [1], 'Pipeline mode does not admit new ground regions')
|
|
|
+})
|
|
|
+
|
|
|
+await check({ count: 1 }, async h => {
|
|
|
+ h.state.visible = true; await h.step(); await h.fail(1)
|
|
|
+ await h.step(); assert.deepEqual(h.started, [1], 'Failure cooldown prevents repeated starts')
|
|
|
+ await h.step(15001)
|
|
|
+ assert.deepEqual(h.started, [1, 1], 'Retry can progress during movement after cooldown')
|
|
|
+})
|
|
|
+
|
|
|
+await check({ count: 1 }, async h => {
|
|
|
+ h.state.visible = true; await h.step()
|
|
|
+ h.state.visible = false; await h.step()
|
|
|
+ const m = await h.resolveLoad(1, true)
|
|
|
+ assert.equal(h.added.length, 0, 'Withdrawn candidate cannot attach after async completion')
|
|
|
+ assert.deepEqual(h.removed, [m])
|
|
|
+ h.state.visible = true; await h.step()
|
|
|
+ assert.deepEqual(h.started, [1, 1], 'Rejected completion releases its slot for a new candidate')
|
|
|
+})
|
|
|
+console.log('PASS moving admission boundaries: progress, streaming cap, static transition, budgets, coarse gate, cancellation, retry')
|