verify-region-loading.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. import assert from 'node:assert/strict'
  2. import {selectDetailRegions,createRegionLoadingController} from '../../src/modules/cockpit/services/regionLoading.js'
  3. import {removeSceneTileset} from '../../src/modules/cockpit/services/tilesetCleanup.js'
  4. function event(){const f=new Set();return{addEventListener(fn){f.add(fn);return()=>f.delete(fn)},raise(...args){for(const fn of f)fn(...args)}}}
  5. function model(){return {tileLoad:event(),tileUnload:event(),tileVisible:event(),tileFailed:event(),loadProgress:event(),tilesLoaded:false}}
  6. const regions=Array.from({length:113},(_,id)=>({id,contentCount:30+(id%4)*20,retryAt:0,distance:10+id,active:false,url:'region'+id}))
  7. const wanted=selectDetailRegions(regions);assert.equal(wanted.length,2);assert.ok(wanted.reduce((s,i)=>s+regions[i].contentCount,0)<=180)
  8. assert.deepEqual(selectDetailRegions(regions,{pipeline:true}),[])
  9. assert.deepEqual(selectDetailRegions(regions.map(r=>({...r,distance:1000}))),[])
  10. const memory=selectDetailRegions(regions.map(r=>({...r,decodedGeometryBytes:60*1024*1024})));assert.equal(memory.length,1)
  11. const retained=selectDetailRegions(regions.map(r=>({...r,active:r.id===20})));assert.equal(retained[0],20)
  12. assert.deepEqual(selectDetailRegions([{id:0,active:true,distance:500,contentCount:20,retryAt:0},{id:1,active:false,distance:10,contentCount:20,retryAt:0}],{maxRegions:1}),[1],'new near region must outrank old distant active region')
  13. let started=[],added=[],removed=[],ticks=0
  14. const models=new Map(),controller=createRegionLoadingController({regions:[{id:0,contentCount:2,expectedContentUrls:['a','b'],retryAt:0,url:'zero'},{id:1,contentCount:2,retryAt:0,url:'one'}],
  15. load:async r=>{started.push(r.id);const m=model();models.set(r.id,m);return m},add:m=>added.push(m),remove:m=>removed.push(m),distance:()=>10,now:()=>ticks,
  16. setTimer:()=>1,clearTimer:()=>{},requestRender:()=>{}})
  17. controller.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(started,[0],'only one initial stream')
  18. const a=models.get(0);assert.equal(a.show,false);assert.equal(a.preloadWhenHidden,true)
  19. a.tileLoad.raise({content:{url:'unrelated'}})
  20. a.tileLoad.raise({content:{url:'a'}});a.tilesLoaded=true;controller.onRender();assert.equal(controller.detailReady(0),false,'one missing content preserves coarse');assert.equal(a.show,false);assert.deepEqual(started,[0])
  21. a.tileLoad.raise({content:{url:'b'}});controller.onRender();await Promise.resolve();await Promise.resolve()
  22. assert.equal(a.show,true);assert.equal(controller.detailReady(0),false,'must observe rendered detail after hidden preload')
  23. a.tileVisible.raise({content:{trianglesLength:1}});controller.onRender()
  24. assert.equal(controller.detailReady(0),true);controller.refresh();assert.equal(controller.detailReady(0),true,'timers must not unmask between rendered frames');assert.deepEqual(started,[0,1],'second admitted only after actual complete first region')
  25. a.tileUnload.raise({content:{url:'b'}});assert.equal(controller.detailReady(0),false,'eviction must restore coarse')
  26. a.tileLoad.raise({content:{url:'b'}});a.tileVisible.raise({content:{trianglesLength:1}});controller.onRender();assert.equal(controller.detailReady(0),true)
  27. controller.setPipeline(true);assert.equal(controller.snapshot().length,0);assert.equal(controller.detailReady(0),false);assert.equal(removed.length,2,'pipeline releases all fine regions')
  28. controller.setPipeline(false);await Promise.resolve();await Promise.resolve();assert.ok(controller.snapshot().length<=2)
  29. const fresh=models.get(0);fresh.tileFailed.raise({message:'test failure'});controller.refresh();assert.equal(controller.detailReady(0),false)
  30. controller.detach();
  31. let gate=false,front=0,frustumStarts=[],frustumRemoved=[]
  32. const frustum=createRegionLoadingController({regions:[{id:0,contentCount:1,retryAt:0},{id:1,contentCount:1,retryAt:0}],load:async r=>{frustumStarts.push(r.id);return model()},add:()=>{},remove:m=>frustumRemoved.push(m),canStart:()=>gate,distance:r=>r.id===front?10:Infinity,setTimer:()=>1,clearTimer:()=>{}})
  33. frustum.refresh();await Promise.resolve();assert.deepEqual(frustumStarts,[],'coarse must finish before fine starts')
  34. gate=true;frustum.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(frustumStarts,[0])
  35. front=1;frustum.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(frustumStarts,[0,1]);assert.equal(frustumRemoved.length,1,'offscreen region must release serial slot')
  36. frustum.detach();
  37. // Late fromUrl completion after view switch must be destroyed, never added.
  38. let resolveLoad,lateAdded=0,lateRemoved=0
  39. const late=createRegionLoadingController({regions:[{id:0,contentCount:1,retryAt:0}],load:()=>new Promise(r=>resolveLoad=r),add:()=>lateAdded++,remove:()=>lateRemoved++,distance:()=>1,setTimer:()=>1,clearTimer:()=>{}})
  40. late.refresh();late.setPipeline(true);resolveLoad(model());await Promise.resolve();await Promise.resolve();assert.equal(lateAdded,0);assert.equal(lateRemoved,1);late.detach()
  41. let ranked=[]
  42. const ranking=createRegionLoadingController({regions:[{id:0,contentCount:1,retryAt:0},{id:1,contentCount:1,retryAt:0}],load:async r=>{ranked.push(r.id);return model()},add:()=>{},remove:()=>{},distance:r=>r.id?1:100,setTimer:()=>1,clearTimer:()=>{}})
  43. ranking.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(ranked,[1],'load must follow nearest priority, not manifest order');ranking.detach()
  44. let previewStarts=[],previewModels=new Map()
  45. const previews=createRegionLoadingController({regions:[{id:0,retryAt:0,contentCount:3,expectedContentUrls:['parent','fineA','fineB'],expectedPreviewContentUrls:['parent']},{id:1,retryAt:0,contentCount:1}],
  46. load:async r=>{previewStarts.push(r.id);const m=model();previewModels.set(r.id,m);return m},add:()=>{},remove:()=>{},distance:()=>1,setTimer:()=>1,clearTimer:()=>{}})
  47. previews.refresh();await Promise.resolve();await Promise.resolve()
  48. const parent=previewModels.get(0);parent.tileLoad.raise({content:{url:'parent'}});parent.tilesLoaded=true;previews.onRender();await Promise.resolve();await Promise.resolve()
  49. assert.equal(parent.show,true);assert.deepEqual(previewStarts,[0,1],'complete visible preview releases serial slot without unused LOD leaves')
  50. parent.tileVisible.raise({content:{trianglesLength:1}});previews.onRender();assert.equal(previews.detailReady(0),true)
  51. assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,false,'preview-only readiness is not fine rendering')
  52. parent.tileLoad.raise({content:{url:'fineA'}});previews.refresh();assert.equal(previews.snapshot().find(r=>r.id===0).fineLoaded,1)
  53. assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,false,'loaded fine is not yet visible')
  54. parent.tileVisible.raise({content:{url:'fineA',trianglesLength:1}});previews.onRender();assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,true)
  55. previews.onRender();assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,false,'next rendered frame without fine clears visibility')
  56. parent.tileVisible.raise({content:{url:'parent',trianglesLength:1}});previews.onRender()
  57. parent.loadProgress.raise(1,1);assert.equal(previews.detailReady(0),true,'loaded preview preserves coarse coverage while fine waits')
  58. parent.tileUnload.raise({content:{url:'parent'}});assert.equal(previews.detailReady(0),false,'missing preview restores global coarse')
  59. previews.detach()
  60. let destroys=0
  61. const unadded={isDestroyed:()=>destroys>0,destroy:()=>{destroys++}}
  62. const deadScene={isDestroyed:()=>true,get primitives(){throw Error('destroyed scene collection accessed')}}
  63. removeSceneTileset(deadScene,unadded);removeSceneTileset(deadScene,unadded);assert.equal(destroys,1)
  64. let collectionRemoved=0
  65. removeSceneTileset({isDestroyed:()=>false,primitives:{remove(){collectionRemoved++;return true}}},{isDestroyed:()=>false,destroy(){throw Error('double destruction')}});assert.equal(collectionRemoved,1)
  66. console.log(JSON.stringify({passed:true,scope:'2-region/180-content admission, serial content readiness, unload/failure coarse restoration, pipeline release, late async completion'}))