171 lines
15 KiB
JavaScript
171 lines
15 KiB
JavaScript
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),ts=require('typescript');
|
|
const names=['home','matching','stage1','stage2','stage3'];
|
|
const read=name=>JSON.parse(fs.readFileSync(`assets/cloud_rise_${name}/prefab/CloudRise.prefab`));
|
|
function fields(a){const p=a.find(o=>o.nodeKeys),out={};for(const[type,plural]of [['node','nodes'],['component','components'],['frame','frames']])p[type+'Keys'].forEach((key,i)=>out[key]=p[plural][i]);return out;}
|
|
function walk(value,fn){if(!value||typeof value!=='object')return;fn(value);Object.values(value).forEach(v=>walk(v,fn));}
|
|
test('startup owns the shared font at a higher priority than cloudRise without a duplicate atlas',()=>{
|
|
const original=JSON.parse(fs.readFileSync('assets/common/font.plist.meta'));
|
|
const scene=JSON.parse(fs.readFileSync('assets/StartScene/LoadScene.fire'));
|
|
const startup=scene.find(o=>o.commonFont);
|
|
assert.equal(startup.commonFont.__uuid__,original.uuid);
|
|
assert(scene[startup.node.__id__]._components.some(r=>scene[r.__id__]===startup));
|
|
assert.equal(JSON.parse(fs.readFileSync('settings/builder.json')).startScene,JSON.parse(fs.readFileSync('assets/StartScene/LoadScene.fire.meta')).uuid);
|
|
assert(!JSON.parse(fs.readFileSync('assets/StartScene.meta')).isBundle);
|
|
assert.match(fs.readFileSync('assets/Script/Load.ts','utf8'),/@property\(cc.SpriteAtlas\)\s+commonFont:\s*cc.SpriteAtlas/);
|
|
const home=JSON.parse(fs.readFileSync('assets/cloud_rise_home.meta'));
|
|
assert.equal(home.priority,6);assert(home.priority<9); // Cocos 2.4 built-in start-scene priority.
|
|
const digits=Array.from({length:10},(_,i)=>original.subMetas[i===9?'scoin_09.png':`scoin_${i}.png`].uuid);
|
|
for(const name of names){
|
|
const refs=new Set();walk(read(name),o=>{if(o.__uuid__)refs.add(o.__uuid__);});
|
|
for(const digit of digits)assert(refs.has(digit),`${name} must share common/font digits`);
|
|
if(name!=='home')assert(JSON.parse(fs.readFileSync(`assets/cloud_rise_${name}.meta`)).priority<home.priority);
|
|
}
|
|
for(const ext of ['png','png.meta','plist','plist.meta'])assert(!fs.existsSync(`assets/cloud_rise_home/images/scoin_digits.${ext}`));
|
|
});
|
|
test('home ships in the main package; four page bundles are emitted for the WeChat grouping hook',()=>{
|
|
for(const name of names){const m=JSON.parse(fs.readFileSync(`assets/cloud_rise_${name}.meta`));assert(m.isBundle);assert.equal(m.bundleName,'cloud_rise_'+name);assert.equal(m.compressionType.wechatgame,name==='home'?'default':'subpackage');assert(!m.isRemoteBundle.wechatgame);}
|
|
assert(!fs.existsSync('assets/cloud_rise/prefab/CloudRise.prefab'));
|
|
});
|
|
test('all split prefabs have valid references and no detached nodes or legacy result pages',()=>{
|
|
for(const name of names){const a=read(name);walk(a,o=>{if('__id__'in o)assert(a[o.__id__],`${name}: ${o.__id__}`);});
|
|
const visited=new Set();function visit(id){if(visited.has(id))return;visited.add(id);const n=a[id];(n._children||[]).forEach(r=>visit(r.__id__));}visit(a[0].data.__id__);
|
|
a.forEach((o,i)=>{if(o.__type__==='cc.Node'){assert(visited.has(i),`${name}: detached ${o._name}`);assert(!['Result','Pool','Rules','ReturnOverview','TotalReward','Solo'].includes(o._name),`${name}: legacy ${o._name}`);}});
|
|
const binding=a.find(o=>o.nodeKeys);if(binding)for(const k of ['nodes','components','frames'])assert(binding[k].every(Boolean),`${name}: missing ${k}`);
|
|
for(const r of a.filter(o=>o.digits&&o.content&&o.coin)){assert.equal(r.digits.length,10);assert.equal(a[r.content.__id__]._children.length,5);}
|
|
}
|
|
});
|
|
test('every authored node has PrefabInfo and its prefab root is safe for the Cocos compiled serializer',()=>{
|
|
for(const name of names){
|
|
const a=read(name),root=a[0].data.__id__;
|
|
const uuid=JSON.parse(fs.readFileSync(`assets/cloud_rise_${name}/prefab/CloudRise.prefab.meta`)).uuid;
|
|
for(const node of a.filter(o=>o.__type__==='cc.Node')){
|
|
assert(node._prefab,`${name}/${node._name}: missing PrefabInfo`);
|
|
const info=a[node._prefab.__id__];assert.equal(info.__type__,'cc.PrefabInfo');
|
|
assert.equal(info.root.__id__,root);
|
|
// Creator may serialize this as a local reference to the owning Prefab after saving.
|
|
if(info.asset.__id__!==undefined){assert.equal(info.asset.__id__,0);assert.equal(a[0].__type__,'cc.Prefab');}
|
|
else assert.equal(info.asset.__uuid__,uuid);
|
|
assert.equal(typeof info.sync,'boolean');
|
|
if(node!==a[root])assert(info.fileId);
|
|
// serialize-compiled/parser.canDiscardByPrefabRoot reads this without a null guard.
|
|
assert.equal(typeof a[a[info.root.__id__]._prefab.__id__].sync,'boolean');
|
|
}
|
|
}
|
|
});
|
|
test('home contains only overview; matching contains only matching; stages own their route and both outcomes',()=>{
|
|
const home=read('home'),p=home.find(o=>o.pages&&o.buttons);
|
|
assert.equal(home[p.pages[0].__id__]._name,'Overview');assert(p.pages.slice(1).every(v=>v===null));assert(!p.stageArt||p.stageArt.length===0);
|
|
assert(!home.some(o=>o._name==='Victory'||o._name==='Matching'||o._name==='Progress'));
|
|
const match=read('matching'),m=fields(match);assert.equal(match[m['pages:1'].__id__]._name,'Matching');assert(!match.some(o=>o._name==='Victory'||o._name==='Progress'));
|
|
for(let stage=1;stage<=3;stage++){const a=read('stage'+stage),f=fields(a);
|
|
assert.equal(a[f['tracks:'+(stage-1)].__id__]._children.length,[4,6,8][stage-1]);
|
|
assert.equal(Object.keys(f).filter(k=>k.startsWith('tracks:')).length,1);
|
|
assert.equal(Object.keys(f).filter(k=>k.startsWith('stageArt:')).length,1);
|
|
for(const k of ['failureMessage','victoryPage','victoryReward','rowTemplate','avatarTemplates:0','avatarTemplates:1','avatarTemplates:2'])assert(f[k],k);
|
|
assert(!a.some(o=>o._name==='Matching'||o._name==='Overview'));
|
|
}
|
|
});
|
|
test('stage and matching prefabs never reference another lazy page bundle',()=>{
|
|
const owners=new Map();for(const name of names){function scan(dir){for(const item of fs.readdirSync(dir,{withFileTypes:true})){const file=dir+'/'+item.name;if(item.isDirectory())scan(file);else if(file.endsWith('.meta')){const m=JSON.parse(fs.readFileSync(file));owners.set(m.uuid,name);Object.values(m.subMetas||{}).forEach(s=>owners.set(s.uuid,name));}}}scan('assets/cloud_rise_'+name);}
|
|
for(const name of names)walk(read(name),o=>{if(o.__uuid__&&owners.has(o.__uuid__))assert([name,'home'].includes(owners.get(o.__uuid__)),`${name} depends on ${owners.get(o.__uuid__)}`);});
|
|
});
|
|
function loaderFixture(){const calls=[],fail=new Set();const cc={Prefab:class{},assetManager:{loadBundle(name,cb){calls.push(name);if(fail.has(name))return cb(Error('offline'));cb(null,{load(path,type,done){done(null,{addRef(){},name});}});}}};const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/module/Config/CloudRiseService.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2018}}).outputText,{exports,cc,require:()=>({default:{}})});return {service:exports.default,calls,fail};}
|
|
test('opening home loads no lazy bundle; concurrent page loads coalesce and failures are retryable',async()=>{
|
|
const {service,calls,fail}=loaderFixture();await service.pagePrefab();assert.deepEqual(calls,['cloud_rise_home']);
|
|
await Promise.all([service.pageFragment('matching'),service.pageFragment('matching')]);assert.deepEqual(calls,['cloud_rise_home','cloud_rise_matching']);
|
|
fail.add('cloud_rise_stage2');await assert.rejects(service.pageFragment('stage',2));fail.clear();await service.pageFragment('stage',2);
|
|
assert.equal(calls.filter(n=>n==='cloud_rise_stage2').length,2);assert(!calls.includes('cloud_rise_stage1'));assert(!calls.includes('cloud_rise_stage3'));
|
|
await assert.rejects(service.pageFragment('stage',4));
|
|
});
|
|
|
|
function panelFixture(){
|
|
const pending=[],attached=[];let starts=0,current={status:'waiting',stage:1},confirm=async()=>true;
|
|
class Node{constructor(){this.children=[];}addChild(n){this.children.push(n);}setSiblingIndex(){}destroy(){this.destroyed=true;}on(type,fn){if(type==='click')this.click=fn;}}
|
|
const service={run:()=>current,stage:()=>({stage:current.stage}),data:()=>({}),startStage:async()=>{starts++;const ok=await confirm();if(ok)current={status:'playing',stage:2};return ok;}};
|
|
const bridge={challengeActive:()=>true,registrationOpen:()=>true,pageFragment:(kind,stage)=>new Promise((resolve,reject)=>pending.push({kind,stage,resolve,reject}))};
|
|
const cc={_decorator:{ccclass:c=>c,property:()=>()=>{}},Component:class{},Node,Label:class{},Sprite:class{},SpriteFrame:class{},String,
|
|
isValid:n=>!!n&&!n.destroyed,warn(){},Tween:{stopAllByTarget(){}},instantiate(prefab){const node=new Node();node.getComponent=()=>({bind(target){attached.push(prefab);}});return node;}};
|
|
const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRisePanel.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2018,experimentalDecorators:true}}).outputText,
|
|
{exports,cc,require:n=>({default:n.endsWith('CloudRiseRuntime')?service:n.endsWith('CloudRiseService')?bridge:class{}})});
|
|
const panel=new exports.default();panel.node=new Node();panel.design=new Node();panel.errorLabel={node:{}};
|
|
panel.buttons=[new Node()];panel.bindButtons();panel.mode='overview';
|
|
panel.show=mode=>{panel.mode=mode;panel.buttons[0].active=!panel.busy||panel.matchPhase==='preparing';};
|
|
panel.matchingCount={string:''};panel.matchingPrompt={active:false};panel.matchingTitle={node:{active:false},string:''};panel.matchingReward={setValue(){}};
|
|
panel.resetMatchingAvatars=()=>{};panel.playMatching=async()=>{panel.matchingCount.string='100/100';};
|
|
return {panel,pending,attached,starts:()=>starts,holdConfirmation:()=>new Promise(resolve=>{confirm=()=>new Promise(done=>resolve(done));})};
|
|
}
|
|
test('closing while a page downloads does not attach stale nodes or throw during promise cleanup',async()=>{
|
|
const f=panelFixture(),task=f.panel.preparePage('progress',1);f.panel.node.destroyed=true;f.panel.pageLoads=null;
|
|
f.pending[0].resolve({});await task;assert.equal(f.attached.length,0);
|
|
});
|
|
test('page download failure leaves enrollment untouched and can be retried',async()=>{
|
|
const f=panelFixture(),task=f.panel.match();assert.deepEqual(f.pending.map(p=>[p.kind,p.stage]),[['matching',1],['stage',2]]);
|
|
f.pending[0].resolve({});f.pending[1].reject(Error('offline'));assert.equal(await task,false);assert.equal(f.starts(),0);
|
|
assert.equal(f.panel.busy,false);assert.match(f.panel.errorLabel.string,/加载失败/);
|
|
const retry=f.panel.preparePage('progress',2);f.pending[2].resolve({});await retry;assert.equal(f.panel.loadedStage,2);
|
|
});
|
|
test('changing stages replaces the old stage root instead of retaining its animated nodes',async()=>{
|
|
const f=panelFixture();let task=f.panel.preparePage('progress',1);f.pending[0].resolve({});await task;
|
|
const first=f.panel.stagePage;task=f.panel.preparePage('progress',2);f.pending[1].resolve({});await task;
|
|
assert(first.destroyed);assert.notEqual(f.panel.stagePage,first);assert.equal(f.panel.loadedStage,2);
|
|
});
|
|
|
|
const settle=async()=>{for(let i=0;i<20;i++)await Promise.resolve();};
|
|
|
|
test('overview prepares a hidden matching page without enrollment and start reuses that preparation',async()=>{
|
|
const f=panelFixture(),p=f.panel;p.warmMatching();p.warmMatching();
|
|
assert.equal(f.pending.length,1);assert.equal(f.pending[0].kind,'matching');assert.equal(f.starts(),0);assert.equal(p.mode,'overview');
|
|
f.pending[0].resolve({});await settle();assert.equal(p.matchingPage.active,false);
|
|
const task=p.match();await settle();assert.equal(f.pending.length,2);assert.equal(f.pending[1].kind,'stage');assert.equal(p.mode,'matching');
|
|
f.pending[1].resolve({});assert.equal(await task,true);assert.equal(f.starts(),1);
|
|
});
|
|
|
|
test('failed hidden page preparation leaves enrollment untouched and manual start retries it',async()=>{
|
|
const f=panelFixture(),p=f.panel;p.warmMatching();f.pending[0].reject(Error('offline'));await settle();
|
|
assert.equal(p.mode,'overview');assert.equal(f.starts(),0);assert.equal(p.busy,false);
|
|
const task=p.match();f.pending[1].resolve({});f.pending[2].resolve({});assert.equal(await task,true);
|
|
});
|
|
|
|
test('matching is visible while the next stage loads; confirmation starts only after stage readiness',async()=>{
|
|
const f=panelFixture(),p=f.panel,confirmation=f.holdConfirmation(),task=p.match();
|
|
f.pending[0].resolve({});await settle();
|
|
assert.equal(p.mode,'matching');assert.equal(p.matchingCount.string,'1/100');assert.equal(p.buttons[0].active,true);assert.equal(f.starts(),0);
|
|
f.pending[1].resolve({});const confirm=await confirmation;
|
|
assert.equal(p.matchPhase,'submitting');assert.equal(p.buttons[0].active,false);assert.equal(p.matchingCount.string,'1/100');
|
|
p.buttons[0].click();assert.equal(p.mode,'matching');assert.equal(p.matchPhase,'submitting');
|
|
assert.equal(await p.match(),false);assert.equal(f.starts(),1);
|
|
confirm(true);assert.equal(await task,true);assert.equal(p.mode,'progress');assert.equal(p.matchingCount.string,'100/100');
|
|
});
|
|
|
|
test('cancelling while stage loads returns to overview and late completion cannot enroll or switch pages',async()=>{
|
|
const f=panelFixture(),p=f.panel,task=p.match();f.pending[0].resolve({});await settle();
|
|
p.buttons[0].click();assert.equal(p.mode,'overview');assert.equal(p.busy,false);
|
|
f.pending[1].resolve({});assert.equal(await task,false);assert.equal(f.starts(),0);assert.equal(p.mode,'overview');
|
|
assert.equal(await p.match(),true);assert.equal(f.starts(),1);assert.equal(p.mode,'progress');
|
|
});
|
|
|
|
test('a cancelled request cannot reset a newer match that shares the in-flight stage download',async()=>{
|
|
const f=panelFixture(),p=f.panel,first=p.match();f.pending[0].resolve({});await settle();
|
|
p.buttons[0].click();const confirmation=f.holdConfirmation(),second=p.match();await settle();
|
|
f.pending[1].resolve({});const confirm=await confirmation;
|
|
assert.equal(await first,false);assert.equal(p.busy,true);assert.equal(p.matchPhase,'submitting');assert.equal(f.starts(),1);
|
|
confirm(true);assert.equal(await second,true);assert.equal(f.starts(),1);
|
|
});
|
|
|
|
test('cancelling before matching loads ignores late resource errors and does not report a stale failure',async()=>{
|
|
const f=panelFixture(),p=f.panel,task=p.match();p.buttons[0].click();
|
|
f.pending[1].reject(Error('offline'));f.pending[0].resolve({});assert.equal(await task,false);
|
|
assert.equal(p.mode,'overview');assert.equal(f.starts(),0);assert(!p.errorLabel.string);
|
|
});
|
|
|
|
test('destroying the panel during preparation never submits enrollment',async()=>{
|
|
const f=panelFixture(),task=f.panel.match();f.pending[0].resolve({});await settle();f.panel.node.destroyed=true;
|
|
f.pending[1].resolve({});assert.equal(await task,false);assert.equal(f.starts(),0);
|
|
});
|
|
|
|
test('failed server confirmation never reaches 100 and leaves the button retryable',async()=>{
|
|
const f=panelFixture(),p=f.panel,confirmation=f.holdConfirmation(),task=p.match();
|
|
f.pending[0].resolve({});f.pending[1].resolve({});const confirm=await confirmation;confirm(false);
|
|
assert.equal(await task,false);assert.equal(p.mode,'overview');assert.equal(p.busy,false);assert.equal(p.matchingCount.string,'1/100');
|
|
});
|