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