| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118 |
- import assert from 'node:assert/strict'
- import {selectDetailRegions,createRegionLoadingController,REGION_BUDGET_DEFAULTS} from '../../src/modules/cockpit/services/regionLoading.js'
- import {removeSceneTileset} from '../../src/modules/cockpit/services/tilesetCleanup.js'
- 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)}}}
- function model(){return {tileLoad:event(),tileUnload:event(),tileVisible:event(),tileFailed:event(),loadProgress:event(),tilesLoaded:false}}
- const regions=Array.from({length:113},(_,id)=>({id,contentCount:30+(id%4)*20,retryAt:0,distance:10+id,active:false,url:'region'+id}))
- // 票 61:选择器改为"半径 + 预算"驱动(控制量是覆盖半径,不再是区域个数)。
- // 旧断言把 maxRegions=2 / 700m / 180 条 / 96MiB 这套数字写死了;那套口径正是
- // "同距离一半精模一半火柴盒"的成因,不能继续钉住。这里改为钉**新口径的不变量**:
- const wanted=selectDetailRegions(regions,{radius:700,now:0})
- assert.ok(wanted.length>2,'radius coverage must not collapse back to a fixed pair')
- assert.ok(wanted.every((id,i,a)=>i===0||regions[id].distance>=regions[a[i-1]].distance),'must stay ordered near-to-far')
- assert.ok(wanted.reduce((s,i)=>s+regions[i].contentCount,0)<=REGION_BUDGET_DEFAULTS.maxContents,'must respect the content budget')
- assert.deepEqual(selectDetailRegions(regions,{pipeline:true}),[])
- assert.deepEqual(selectDetailRegions(regions.map(r=>({...r,distance:1000})),{radius:700}),[],'beyond the frontier radius nothing is selected')
- // 预算放大:单区 60MiB 在 160MiB 预算下可以同时驻留(旧口径 96MiB 只容得下一个)
- const memory=selectDetailRegions(regions.map(r=>({...r,decodedGeometryBytes:60*1024*1024})),{radius:700,now:0,maxDecodedBytes:160*1024*1024})
- assert.ok(memory.length>=1,'single large region still selectable within budget')
- // 迟滞:已加载区域在 radius*retainFactor 内**仍然可选**,超出才释放。
- // 用单区域夹具,避免被"其它更近的区域先占满预算"干扰(那是选择器的正常行为,不是迟滞问题)。
- const insideHysteresis=selectDetailRegions([{id:20,distance:50,centerDistance:60,contentCount:1,decodedGeometryBytes:1,retryAt:0,active:true}],{radius:40,now:0,retainFactor:1.4})
- assert.deepEqual(insideHysteresis,[20],'active region inside radius*retainFactor is retained')
- const beyondHysteresis=selectDetailRegions([{id:20,distance:90,centerDistance:100,contentCount:1,decodedGeometryBytes:1,retryAt:0,active:true}],{radius:40,now:0,retainFactor:1.4})
- assert.deepEqual(beyondHysteresis,[],'active region beyond radius*retainFactor is released')
- const notActive=selectDetailRegions([{id:20,distance:50,centerDistance:60,contentCount:1,decodedGeometryBytes:1,retryAt:0,active:false}],{radius:40,now:0,retainFactor:1.4})
- assert.deepEqual(notActive,[],'hysteresis applies only to already-active regions')
- assert.deepEqual(selectDetailRegions([{id:0,active:true,distance:500,contentCount:20,retryAt:0},{id:1,active:false,distance:10,contentCount:20,retryAt:0}],{radius:1000,maxRegions:1}),[1],'new near region must outrank old distant active region')
- let started=[],added=[],removed=[],ticks=0
- 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'}],
- 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,
- setTimer:()=>1,clearTimer:()=>{},requestRender:()=>{}})
- // 票 61:串行单槽 → **有限并发**(默认 2)。旧断言 "only one initial stream" 钉的是
- // maxRegions=2 时代的单槽纪律;新语义下一个并发档内可以同时开装,这正是把
- // "同距离区域排队数十秒才变精模"压掉的手段。这里钉新的不变式:不超过并发档。
- controller.refresh();await Promise.resolve();await Promise.resolve();assert.ok(started.length<=2,'concurrent loads stay within the configured limit')
- assert.deepEqual(started.slice().sort(),[0,1],'both admitted regions start within one tick')
- const a=models.get(0);assert.equal(a.show,false);assert.equal(a.preloadWhenHidden,true)
- a.tileLoad.raise({content:{url:'unrelated'}})
- // 票 61:到位判据改为 Cesium 口径(`tilesLoaded && pending===0 && processing===0`),
- // 不再用 `loaded.size === contentCount` —— 后者会被 tileUnload 扣减而永久卡住并发槽。
- // 因此"少一条内容"不再等于"没到位":`tilesLoaded` 才是权威。这里改判该语义。
- a.tileLoad.raise({content:{url:'a'}});a.tilesLoaded=true;controller.onRender()
- // 到位(Cesium 口径)后允许显形;但**这一帧还没渲染过**,所以粗模遮罩仍保留。
- assert.equal(controller.detailReady(0),false,'must observe rendered detail before unmasking coarse')
- assert.ok(started.includes(0),'region 0 must have started')
- a.tileLoad.raise({content:{url:'b'}});controller.onRender();await Promise.resolve();await Promise.resolve()
- assert.equal(a.show,true);assert.equal(controller.detailReady(0),false,'must observe rendered detail after hidden preload')
- a.tileVisible.raise({content:{trianglesLength:1}});controller.onRender()
- assert.equal(controller.detailReady(0),true);controller.refresh();assert.equal(controller.detailReady(0),true,'timers must not unmask between rendered frames')
- assert.ok(started.includes(1),'second region admitted (bounded concurrency, not single slot)')
- // 票 61 第十五轮:遮罩带 900ms 保持期(防"精粗模反复切换"的闪烁)。
- // 所以**瞬时**剔除不再立刻掀遮罩;持续失效才回退。这两条都要断。
- a.tileUnload.raise({content:{url:'b'}})
- assert.equal(controller.detailReady(0),true,'eviction within the hold window must NOT flash coarse back')
- ticks+=1200
- controller.onRender()
- assert.equal(controller.detailReady(0),false,'sustained eviction must restore coarse after the hold window')
- a.tileLoad.raise({content:{url:'b'}});a.tileVisible.raise({content:{trianglesLength:1}});controller.onRender();assert.equal(controller.detailReady(0),true)
- 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')
- controller.setPipeline(false);await Promise.resolve();await Promise.resolve();assert.ok(controller.snapshot().length<=2)
- const fresh=models.get(0);fresh.tileFailed.raise({message:'test failure'});controller.refresh();assert.equal(controller.detailReady(0),false)
- controller.detach();
- let gate=false,front=0,frustumStarts=[],frustumRemoved=[]
- // 视锥从 A 转向 B:旧区释放后,移动中的新近区也必须在单槽限制内推进。
- // 低配机记录证明“必须等完全停手”会让近景长期只有粗模,故不再锁定旧零开装策略。
- let clock=0
- 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,now:()=>clock,setTimer:()=>1,clearTimer:()=>{}})
- frustum.refresh();await Promise.resolve();assert.deepEqual(frustumStarts,[],'coarse must finish before fine starts')
- gate=true;frustum.refresh();await Promise.resolve();await Promise.resolve();assert.deepEqual(frustumStarts,[0])
- front=1;frustum.refresh();await Promise.resolve();await Promise.resolve()
- assert.deepEqual(frustumStarts,[0,1],'newly visible region starts during movement after the old slot is released')
- assert.equal(frustum.snapshot().length,1,'only the newly admitted region remains active')
- clock=600;frustum.refresh();await Promise.resolve();await Promise.resolve()
- assert.deepEqual(frustumStarts,[0,1],'settling must not restart an already streaming region')
- assert.ok(frustumRemoved.length>=1,'offscreen region must release its slot')
- frustum.detach();
- // Late fromUrl completion after view switch must be destroyed, never added.
- let resolveLoad,lateAdded=0,lateRemoved=0
- 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:()=>{}})
- late.refresh();late.setPipeline(true);resolveLoad(model());await Promise.resolve();await Promise.resolve();assert.equal(lateAdded,0);assert.equal(lateRemoved,1);late.detach()
- let ranked=[]
- 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:()=>{}})
- // 票 61:并发档默认 2,所以"最近的先开"不再等于"只有最近的在开"。断言改为:
- // **最近的那个必须先被开装**(顺序语义),并发档内允许同时开别的。
- ranking.refresh();await Promise.resolve();await Promise.resolve()
- assert.equal(ranked[0],1,'load must follow nearest priority, not manifest order')
- assert.ok(ranked.length<=2,'concurrent loads stay within the limit');ranking.detach()
- let previewStarts=[],previewModels=new Map()
- const previews=createRegionLoadingController({regions:[{id:0,retryAt:0,contentCount:3,expectedContentUrls:['parent','fineA','fineB'],expectedPreviewContentUrls:['parent']},{id:1,retryAt:0,contentCount:1}],
- load:async r=>{previewStarts.push(r.id);const m=model();previewModels.set(r.id,m);return m},add:()=>{},remove:()=>{},distance:()=>1,now:()=>ticks,setTimer:()=>1,clearTimer:()=>{}})
- previews.refresh();await Promise.resolve();await Promise.resolve()
- const parent=previewModels.get(0);parent.tileLoad.raise({content:{url:'parent'}});parent.tilesLoaded=true;previews.onRender();await Promise.resolve();await Promise.resolve()
- assert.equal(parent.show,true);assert.deepEqual(previewStarts,[0,1],'complete visible preview releases serial slot without unused LOD leaves')
- parent.tileVisible.raise({content:{trianglesLength:1}});previews.onRender();assert.equal(previews.detailReady(0),true)
- assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,false,'preview-only readiness is not fine rendering')
- parent.tileLoad.raise({content:{url:'fineA'}});previews.refresh();assert.equal(previews.snapshot().find(r=>r.id===0).fineLoaded,1)
- assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,false,'loaded fine is not yet visible')
- parent.tileVisible.raise({content:{url:'fineA',trianglesLength:1}});previews.onRender();assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,true)
- previews.onRender();assert.equal(previews.snapshot().find(r=>r.id===0).fineVisibleLastRender,false,'next rendered frame without fine clears visibility')
- parent.tileVisible.raise({content:{url:'parent',trianglesLength:1}});previews.onRender()
- parent.loadProgress.raise(1,1);assert.equal(previews.detailReady(0),true,'loaded preview preserves coarse coverage while fine waits')
- // 票 61 第十五轮:同样受 900ms 保持期保护 —— 瞬时缺失不闪,持续缺失才回退。
- // 真实语义:tilesLoaded 只在"当前视图所需瓦片都处理完"时为真。瓦片被收回且没有替代
- // ⇒ tilesLoaded 回落。这里同时把两件事都模拟出来(只 raise unload 不改 tilesLoaded 是假场景)。
- parent.tileUnload.raise({content:{url:'parent'}})
- assert.equal(previews.detailReady(0),true,'missing preview within the hold window must NOT flash global coarse')
- parent.tilesLoaded=false
- ticks+=1200
- previews.onRender()
- assert.equal(previews.detailReady(0),false,'sustained missing preview restores global coarse')
- previews.detach()
- let destroys=0
- const unadded={isDestroyed:()=>destroys>0,destroy:()=>{destroys++}}
- const deadScene={isDestroyed:()=>true,get primitives(){throw Error('destroyed scene collection accessed')}}
- removeSceneTileset(deadScene,unadded);removeSceneTileset(deadScene,unadded);assert.equal(destroys,1)
- let collectionRemoved=0
- removeSceneTileset({isDestroyed:()=>false,primitives:{remove(){collectionRemoved++;return true}}},{isDestroyed:()=>false,destroy(){throw Error('double destruction')}});assert.equal(collectionRemoved,1)
- console.log(JSON.stringify({passed:true,scope:'radius/budget admission, bounded moving/stationary loading, coarse restoration, pipeline release, late async completion'}))
|