import assert from 'node:assert/strict' import { createTilesetLoadingController } from '../../../src/modules/cockpit/services/tilesetLoading.js' function event() { const listeners = new Set() return { addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn) }, emit(...args) { for (const fn of listeners) fn(...args) }, get count() { return listeners.size } } } function tile() { return { show: true, tilesLoaded: false, destroyed: false, loadProgress: event(), tileVisible: event(), tileFailed: event(), isDestroyed() { return this.destroyed } } } function deferred() { let resolve, reject const promise = new Promise((a, b) => { resolve = a; reject = b }) return { promise, resolve, reject } } function fixture(plans, overrides = {}) { let clock = 0, nextId = 0 const timers = new Map() const state = { started: [], added: [], removed: [], instances: [], failed: [], pose: { position: [0, 0, 0], direction: [1, 0, 0], up: [0, 0, 1] }, distances: {}, signals: [] } const controller = createTilesetLoadingController({ plans, instances: state.instances, failed: state.failed, prepareBounds: async (plan, signal) => { state.signals.push(signal); return { url: plan.url } }, readCamera: () => state.pose, distanceTo: (bounds) => state.distances[bounds.url] ?? 0, load: async (plan) => { const instance = tile(); state.started.push({ url: plan.url, instance }); return instance }, add: (instance) => state.added.push(instance), remove: (instance) => { assert.equal(instance.destroyed, false, 'double destruction'); instance.destroyed = true; state.removed.push(instance) }, now: () => clock, setTimer: (fn, delay) => { const id = ++nextId; timers.set(id, { at: clock + delay, fn }); return id }, clearTimer: (id) => timers.delete(id), ...overrides, }) const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() } async function advance(ms) { const until = clock + ms for (let count = 0; count < 10000; count++) { const next = [...timers].sort((a, b) => a[1].at - b[1].at)[0] if (!next || next[1].at > until) break clock = next[1].at timers.delete(next[0]) next[1].fn() await flush() } clock = until await flush() } return { ...state, state, controller, advance, flush, timers } } const plans = [{ url: 'a', eager: false }, { url: 'b', eager: false }] // Regression: metadata-ready must NOT release the slot while the first GLB is processing. { const f = fixture(plans) await f.controller.prepare() await f.advance(600) assert.deepEqual(f.started.map((x) => x.url), ['a']) const a = f.started[0].instance a.loadProgress.emit(0, 4) f.controller.onRender() await f.advance(2000) assert.equal(f.started.length, 1, 'second stream overlapped first GLB processing') a.tilesLoaded = true a.loadProgress.emit(0, 0) a.tileVisible.emit({ content: { trianglesLength: 12 } }) await f.advance(800) assert.deepEqual(f.started.map((x) => x.url), ['a', 'b']) f.controller.detach() } // Camera rotation, small cumulative moves, and idle delay all matter; altitude is not a switch. { const f = fixture(plans) await f.controller.prepare() await f.advance(400) f.state.pose.direction = [0, 1, 0] f.controller.refresh() await f.advance(400) assert.equal(f.started.length, 0, 'rotation was ignored by the idle gate') f.state.pose.position = [0.1, 0, 0] f.controller.refresh() f.state.pose.position = [0.3, 0, 0] f.controller.refresh() await f.advance(400) assert.equal(f.started.length, 0, 'small cumulative moves bypassed the idle gate') await f.advance(200) assert.equal(f.started.length, 1) f.controller.detach() } // Coarse fallback stays visible until actual triangles are shown; empty root anchor is not enough. { const f = fixture([{ url: 'far', role: 'far', eager: true }, ...plans]) await f.controller.prepare() const far = f.started[0].instance far.tilesLoaded = true f.controller.onRender() await f.advance(1000) const fine = f.started[1].instance f.controller.refresh() assert.equal(far.show, true, 'JSON initialization hid the fallback') fine.tilesLoaded = true fine.tileVisible.emit({ content: { trianglesLength: 0 } }) f.controller.onRender() f.controller.refresh() assert.equal(far.show, true, 'empty root anchor hid the fallback') fine.tileVisible.emit({ content: { trianglesLength: 10 } }) f.controller.onRender() f.controller.refresh() assert.equal(far.show, false) f.controller.onRender() f.controller.refresh() assert.equal(far.show, true, 'previously visible content must not hide the fallback after leaving the frustum') f.controller.detach() } // An older, ready tileset may stream again on camera movement. That work blocks a new initial stream. { const f = fixture(plans) await f.controller.prepare() await f.advance(600) const a = f.started[0].instance a.tilesLoaded = true f.controller.onRender() await f.advance(400) a.loadProgress.emit(1, 0) await f.advance(600) assert.equal(f.started.length, 1) a.loadProgress.emit(0, 0) await f.advance(600) assert.equal(f.started.length, 2) f.controller.detach() } // Failed initialization must be recorded once, not retry recursively forever. { const f = fixture(plans, { load: async () => { throw new Error('bad content') } }) await f.controller.prepare() await f.advance(5000) assert.equal(f.failed.length, 2) assert.deepEqual(f.controller.snapshot().map((x) => x.phase), ['failed', 'failed']) f.controller.detach() } // Distance unload calls the owner once and removes the shared reference. { const f = fixture([plans[0]]) await f.controller.prepare() await f.advance(600) f.state.distances.a = 50000 f.controller.refresh() f.controller.refresh() assert.equal(f.removed.length, 1) assert.equal(f.instances.length, 0) f.controller.detach() } // Late async initialization after disposal must be destroyed, not resurrected in the scene. { const pending = deferred() const f = fixture([plans[0]], { load: () => pending.promise }) await f.controller.prepare() await f.advance(600) f.controller.detach() const instance = tile() pending.resolve(instance) await f.flush() assert.equal(f.added.length, 0) assert.equal(f.removed.length, 1) assert.ok(f.signals.every((signal) => signal.aborted)) assert.equal(f.timers.size, 0) } // Detach cleans every event subscription and timer. { const f = fixture([plans[0]]) await f.controller.prepare() await f.advance(600) const a = f.started[0].instance f.controller.detach() assert.equal(a.loadProgress.count + a.tileFailed.count + a.tileVisible.count, 0) assert.equal(f.timers.size, 0) } // All initialization failures are terminal; metadata failure cannot silently enter the pool. { const f = fixture(plans, { prepareBounds: async () => { throw new Error('missing bounds') } }) await f.controller.prepare() await f.advance(5000) assert.equal(f.started.length, 0) assert.equal(f.failed.length, 2) f.controller.detach() } // Eager entries share the content gate too; empty/offscreen roots can release after a real frame. { const f = fixture([{ url: 'a', eager: true }, { url: 'b', eager: true }]) await f.controller.prepare() const a = f.started[0].instance a.tilesLoaded = true await f.advance(1000) assert.equal(f.started.length, 1, 'an unrendered eager entry released the content slot') f.controller.onRender() await f.advance(600) assert.equal(f.started.length, 2) f.controller.detach() f.controller.detach() } // A tile failure releases the stream after pending work drains, without retrying the batch. { const f = fixture(plans) await f.controller.prepare() await f.advance(600) const a = f.started[0].instance a.tileFailed.emit({ url: 'a.glb', message: 'decode failure' }) f.controller.onRender() await f.advance(600) assert.equal(f.started.length, 2) assert.equal(f.failed.length, 1) assert.equal(f.controller.snapshot()[0].phase, 'failed') f.controller.detach() } // Hidden coarse layer at the global camera must not hold the content gate forever. { const f = fixture([{ url: 'far', role: 'far', eager: true }, plans[0]]) f.state.distances.far = 2000000 await f.controller.prepare() const far = f.started[0].instance assert.equal(far.show, false) assert.equal(far.tilesLoaded, false) f.controller.onRender() await f.advance(1000) assert.deepEqual(f.started.map((item) => item.url), ['far', 'a'], 'hidden coarse layer blocked the queue') f.controller.detach() } console.log('T35 tileset loading: 12 scenarios passed')