|
|
@@ -0,0 +1,208 @@
|
|
|
+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) } }
|
|
|
+}
|
|
|
+const flush = async () => { await Promise.resolve(); await Promise.resolve() }
|
|
|
+let clock = 0, front = 0
|
|
|
+const loads = [], removed = [], models = new Map()
|
|
|
+const controller = createRegionLoadingController({
|
|
|
+ regions: [0, 1].map(id => ({ id, contentCount: 1, decodedGeometryBytes: 1024 })),
|
|
|
+ budget: { maxContents: 2, maxDecodedBytes: 2048 },
|
|
|
+ distance: r => r.id === front ? 10 : Infinity, readCameraKey: () => String(front), now: () => clock,
|
|
|
+ load: async r => { loads.push(r.id); const m = { id: r.id, show: false, tilesLoaded: true, covered: true, tileVisible: event() }; models.set(r.id, m); return m },
|
|
|
+ add() {}, remove: m => removed.push(m.id), coverageReady: e => e.instance.covered,
|
|
|
+ canStart: () => true, requestRender() {}, setTimer: () => 1, clearTimer() {},
|
|
|
+})
|
|
|
+try {
|
|
|
+ controller.refresh(); await flush(); controller.onRender()
|
|
|
+ const a = models.get(0)
|
|
|
+ a.tileVisible.raise({ content: { trianglesLength: 1 } }); controller.onRender()
|
|
|
+ assert.equal(controller.detailReady(0), true, 'Initial A coverage was rendered')
|
|
|
+ clock = 200; front = 1; controller.refresh(); await flush(); controller.onRender()
|
|
|
+ clock = 216; controller.onRender() // next frame has an unused show slot
|
|
|
+ clock = 400; front = 0; controller.refresh(); await flush()
|
|
|
+ assert.deepEqual(loads, [0, 1], 'A -> B -> A within the existing budget must reuse ready A, not reload it')
|
|
|
+ assert.equal(models.get(0), a, 'Reuse the existing model instance')
|
|
|
+ assert.equal(a.show, false, 'Cached readiness must not show before a fresh renderer pass')
|
|
|
+ assert.equal(controller.snapshot().find(e => e.id === 0).awaitingRender, true)
|
|
|
+ a.tilesLoaded = a.covered = false // renderer discovers an uncovered part of the new view
|
|
|
+ controller.onRender()
|
|
|
+ assert.equal(a.show, false, 'Fresh view readiness overrides the cached ready flag')
|
|
|
+ assert.equal(controller.detailReady(0), false, 'Reused model must render again before coarse yields')
|
|
|
+ a.tilesLoaded = a.covered = true; clock = 416; controller.onRender()
|
|
|
+ assert.equal(controller.detailReady(0), false)
|
|
|
+ a.tileVisible.raise({ content: { trianglesLength: 1 } }); clock = 432; controller.onRender()
|
|
|
+ assert.equal(controller.detailReady(0), true)
|
|
|
+ assert.deepEqual(removed, [], 'No eviction needed while both models fit the original budget')
|
|
|
+ console.log('PASS bounded residency: ready A -> B -> A reuses A and re-proves visible coverage')
|
|
|
+} finally { controller.detach() }
|
|
|
+
|
|
|
+async function scene(options, run) {
|
|
|
+ const costs = options.costs || [1, 1, 1]
|
|
|
+ const budget = options.budget || { maxContents: 2, maxDecodedBytes: 2048 }
|
|
|
+ let time = 0, pose = 0, visible = [0], parentReady = true, renderRequested = false
|
|
|
+ const loaded = [], added = [], disposed = [], byId = new Map(), waiting = new Map()
|
|
|
+ const control = createRegionLoadingController({
|
|
|
+ regions: costs.map((cost, id) => ({ id, contentCount: cost, decodedGeometryBytes: cost * 1024 })),
|
|
|
+ budget, distance: r => visible.includes(r.id) ? r.id + 1 : Infinity,
|
|
|
+ readCameraKey: () => String(pose), now: () => time, canStart: () => parentReady,
|
|
|
+ coverageReady: e => e.instance.covered,
|
|
|
+ load: r => {
|
|
|
+ loaded.push(r.id)
|
|
|
+ const m = { id: r.id, show: false, covered: options.ready !== false, tilesLoaded: options.ready !== false,
|
|
|
+ tileVisible: event(), tileFailed: event() }
|
|
|
+ byId.set(r.id, m)
|
|
|
+ return (options.deferred || options.deferredIds?.includes(r.id)) ? new Promise((resolve, reject) => waiting.set(r.id, { resolve: () => resolve(m), reject })) : Promise.resolve(m)
|
|
|
+ },
|
|
|
+ add: m => added.push(m.id), remove: m => disposed.push(m.id),
|
|
|
+ requestRender: () => { renderRequested = true }, setTimer: () => 1, clearTimer() {},
|
|
|
+ })
|
|
|
+ const h = {
|
|
|
+ control, loaded, added, disposed, byId,
|
|
|
+ async view(ids, elapsed = 200, moving = true) {
|
|
|
+ time += elapsed; visible = ids; if (moving) pose++
|
|
|
+ control.refresh(); await flush(); control.onRender(); await flush()
|
|
|
+ },
|
|
|
+ async resolve(id) { waiting.get(id).resolve(); waiting.delete(id); await flush() },
|
|
|
+ async reject(id) { waiting.get(id).reject(new Error('load rejected')); waiting.delete(id); await flush() },
|
|
|
+ parent(value) { parentReady = value; control.refresh() },
|
|
|
+ renderPending: () => renderRequested,
|
|
|
+ clearRenderRequest() { renderRequested = false },
|
|
|
+ checkBudget() {
|
|
|
+ const rows = control.snapshot()
|
|
|
+ assert.ok(rows.reduce((n, e) => n + e.contents, 0) <= (budget.maxContents ?? 1200))
|
|
|
+ assert.ok(rows.reduce((n, e) => n + e.decodedGeometryEstimate, 0) <= (budget.maxDecodedBytes ?? 256 * 1024 * 1024))
|
|
|
+ if (budget.maxRegions !== undefined) assert.ok(rows.length <= Math.ceil(budget.maxRegions))
|
|
|
+ },
|
|
|
+ }
|
|
|
+ control.refresh(); await flush(); control.onRender(); await flush()
|
|
|
+ try { await run(h) } finally {
|
|
|
+ control.detach()
|
|
|
+ for (const wait of waiting.values()) wait.resolve()
|
|
|
+ await flush()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+await scene({}, async h => {
|
|
|
+ await h.view([1])
|
|
|
+ const a = h.byId.get(0)
|
|
|
+ assert.equal(a.show, false)
|
|
|
+ assert.equal(a.preloadWhenHidden, false, 'Parked ready model does not preload or render')
|
|
|
+ assert.equal(h.control.detailReady(0), false, 'Cached region cannot mask coarse')
|
|
|
+ assert.equal(h.control.snapshot().find(e => e.id === 0).cached, true)
|
|
|
+ assert.equal(h.control.frontierSnapshot().cachedRegions, 1)
|
|
|
+ h.clearRenderRequest()
|
|
|
+ await h.view([0])
|
|
|
+ assert.equal(h.renderPending(), true, 'Reactivation wakes requestRenderMode')
|
|
|
+ assert.equal(a.preloadWhenHidden, true)
|
|
|
+ assert.deepEqual(h.loaded, [0, 1])
|
|
|
+ h.checkBudget()
|
|
|
+})
|
|
|
+
|
|
|
+await scene({}, async h => {
|
|
|
+ await h.view([1]); await h.view([0]); await h.view([2])
|
|
|
+ assert.deepEqual(h.disposed, [1], 'Least recently wanted hidden cache yields first')
|
|
|
+ assert.equal(h.control.snapshot().find(e => e.id === 0).cached, true)
|
|
|
+ await h.view([0])
|
|
|
+ assert.deepEqual(h.loaded, [0, 1, 2], 'Most recently reused cache survives pressure')
|
|
|
+ h.checkBudget()
|
|
|
+})
|
|
|
+
|
|
|
+await scene({}, async h => {
|
|
|
+ await h.view([1]); await h.view([0]); await h.view([0, 2])
|
|
|
+ assert.deepEqual(h.disposed, [1], 'Cache yields instead of evicting the active near target')
|
|
|
+ assert.equal(h.byId.get(0).show, true)
|
|
|
+ h.checkBudget()
|
|
|
+})
|
|
|
+
|
|
|
+for (const budget of [{ maxContents: 1 }, { maxDecodedBytes: 1024 }, { maxRegions: 1 }]) {
|
|
|
+ await scene({ budget }, async h => {
|
|
|
+ await h.view([1])
|
|
|
+ assert.deepEqual(h.disposed, [0], 'Content, bytes and optional count limits include cached models')
|
|
|
+ assert.deepEqual(h.control.snapshot().map(e => e.id), [1])
|
|
|
+ h.checkBudget()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+await scene({ deferred: true, budget: { maxContents: 1, maxDecodedBytes: 1024 } }, async h => {
|
|
|
+ await h.view([1], 1000, false)
|
|
|
+ assert.deepEqual(h.loaded, [0], 'Withdrawn unresolved initialization still reserves its budget')
|
|
|
+ h.checkBudget()
|
|
|
+ await h.resolve(0)
|
|
|
+ assert.deepEqual(h.added, [], 'Late withdrawn initialization never attaches')
|
|
|
+ await h.view([1], 1000, false)
|
|
|
+ assert.deepEqual(h.loaded, [0, 1], 'Released initialization reservation permits the new target')
|
|
|
+ h.checkBudget()
|
|
|
+})
|
|
|
+
|
|
|
+await scene({ ready: false }, async h => {
|
|
|
+ await h.view([1])
|
|
|
+ assert.deepEqual(h.disposed, [0], 'Incomplete initial stream is not retained')
|
|
|
+ assert.equal(h.control.frontierSnapshot().cachedRegions, 0)
|
|
|
+})
|
|
|
+await scene({ costs: [0, 0, 0] }, async h => {
|
|
|
+ await h.view([1])
|
|
|
+ assert.deepEqual(h.disposed, [0], 'Unknown zero-cost models cannot accumulate unbounded caches')
|
|
|
+})
|
|
|
+
|
|
|
+await scene({}, async h => {
|
|
|
+ await h.view([1])
|
|
|
+ h.byId.get(0).tileFailed.raise({ message: 'late cached failure' })
|
|
|
+ h.control.refresh()
|
|
|
+ assert.deepEqual(h.disposed, [0], 'Failure removes a cached instance')
|
|
|
+ assert.equal(h.control.frontierSnapshot().cachedRegions, 0)
|
|
|
+})
|
|
|
+await scene({}, async h => {
|
|
|
+ await h.view([1]); h.parent(false)
|
|
|
+ assert.deepEqual(h.disposed, [0], 'Unavailable parent releases hidden caches')
|
|
|
+ assert.equal(h.control.frontierSnapshot().cachedRegions, 0)
|
|
|
+})
|
|
|
+await scene({}, async h => {
|
|
|
+ await h.view([1]); await h.view([], 1000, false)
|
|
|
+ assert.equal(h.control.snapshot().length, 0, 'Settled scene without demand releases residency')
|
|
|
+})
|
|
|
+await scene({}, async h => {
|
|
|
+ await h.view([1]); h.control.setPipeline(true)
|
|
|
+ assert.equal(h.control.snapshot().length, 0)
|
|
|
+ assert.deepEqual(h.disposed.slice().sort(), [0, 1], 'Pipeline releases active and cached models')
|
|
|
+ h.control.detach()
|
|
|
+ assert.equal(h.disposed.length, 2, 'Dispose does not release a model twice')
|
|
|
+})
|
|
|
+await scene({ costs: [1, 1, 3], budget: { maxContents: 3, maxDecodedBytes: 3072 }, deferredIds: [1] }, async h => {
|
|
|
+ await h.view([1]) // ready A is cached; B is still initializing.
|
|
|
+ await h.view([2], 1000, false)
|
|
|
+ assert.deepEqual(h.loaded, [0, 1], 'Outstanding B prevents a full-budget C from starting')
|
|
|
+ assert.deepEqual(h.disposed.slice(), [], 'Do not evict A when even clearing caches cannot yet admit C')
|
|
|
+ await h.resolve(1)
|
|
|
+ await h.view([2], 1000, false)
|
|
|
+ assert.deepEqual(h.loaded, [0, 1, 2], 'C starts after the obsolete reservation resolves')
|
|
|
+ assert.deepEqual(h.disposed.slice().sort(), [0, 1])
|
|
|
+ h.checkBudget()
|
|
|
+})
|
|
|
+await scene({ deferred: true }, async h => {
|
|
|
+ await h.view([0, 1, 2], 1000, false)
|
|
|
+ assert.deepEqual(h.loaded, [0, 1], 'Two unresolved initializations fill the shared budget')
|
|
|
+ h.checkBudget()
|
|
|
+ await h.view([2], 1000, false)
|
|
|
+ assert.deepEqual(h.loaded, [0, 1])
|
|
|
+ await h.resolve(0)
|
|
|
+ await h.view([2], 1000, false)
|
|
|
+ assert.deepEqual(h.loaded, [0, 1, 2], 'Only released reservation is replaced')
|
|
|
+ h.checkBudget()
|
|
|
+})
|
|
|
+await scene({ deferredIds: [1] }, async h => {
|
|
|
+ await h.view([1]); await h.reject(1); await h.view([2])
|
|
|
+ assert.deepEqual(h.loaded, [0, 1, 2], 'Rejected load releases capacity for the next target')
|
|
|
+ assert.equal(h.control.snapshot().find(e => e.id === 0).cached, true, 'Unrelated valid cache survives rejection')
|
|
|
+ h.checkBudget()
|
|
|
+})
|
|
|
+await scene({ deferredIds: [1] }, async h => {
|
|
|
+ await h.view([1]); h.control.detach(); await h.resolve(1)
|
|
|
+ assert.deepEqual(h.added, [0], 'Disposed controller never attaches a late model')
|
|
|
+ assert.deepEqual(h.disposed.slice().sort(), [0, 1], 'Cache and late model are each released once')
|
|
|
+ assert.equal(h.control.snapshot().length, 0)
|
|
|
+})
|
|
|
+console.log('PASS residency boundaries: hidden state, wakeup, LRU, budgets, pending reservation, failure and release')
|