| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- 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')
|