// Run: node --test tools/test-career-loading.cjs // Set JIAZAI_TEST_REF=HEAD to reproduce the regression against committed source. const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require(process.env.TYPESCRIPT_PATH || 'typescript'); const root = path.resolve(__dirname, '..'); const filename = 'assets/Script/JiaZai.ts'; const source = process.env.JIAZAI_TEST_REF ? require('node:child_process').execFileSync('git', ['show', `${process.env.JIAZAI_TEST_REF}:${filename}`], { cwd: root, encoding: 'utf8' }) : fs.readFileSync(path.join(root, filename), 'utf8'); const parsed = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true); const declaration = parsed.statements.find(node => ts.isClassDeclaration(node) && node.name.text === 'JiaZai'); const methods = ['LoadCareer', 'getSRank', 'isHomeBundleReady', 'loadHomeBundleWithDependencies'].map(name => { const method = declaration.members.find(node => ts.isMethodDeclaration(node) && node.name.getText(parsed) === name); assert.ok(method, `JiaZai.${name} must exist`); return method.getText(parsed); }); const compiled = ts.transpileModule(`class Subject { ${methods.join('\n')} }\nSubject;`, { compilerOptions: { target: ts.ScriptTarget.ES2017 }, }).outputText; function fixture(cachedAction = false) { const state = { bundles: new Map(), bundleLoads: [], prefabLoads: [], errors: [], requests: 0, instances: 0, lowMemory: false, dependencies: {}, bundleErrors: {} }; const pendingAction = []; const action = { name: 'action_bundle' }; if (cachedAction) state.bundles.set(action.name, action); const cc = { Prefab: class {}, warn: (...args) => state.errors.push(args.map(String).join(' ')), isValid: node => !!node && node.valid !== false, error: (...args) => state.errors.push(args.map(String).join(' ')), instantiate: prefab => { state.instances++; return { prefab }; }, assetManager: { getBundle: name => state.bundles.get(name), loadBundle(name, options, done) { state.bundleLoads.push(name); if (state.bundleErrors[name]) return done(state.bundleErrors[name]); if (name === 'action_bundle') return pendingAction.push(done); const bundle = { name, deps: state.dependencies[name] || [], load(asset, type, loaded) { state.prefabLoads.push(asset); assert.equal(type, cc.Prefab); // Model Cocos refusing to resolve cross-bundle prefab dependencies. if (!state.bundles.has('action_bundle')) { return loaded(new Error('Please load bundle action_bundle first')); } loaded(null, { asset }); }, }; state.bundles.set(name, bundle); done(null, bundle); }, }, }; const Subject = vm.runInNewContext(compiled, { cc, Utils: { getSRank: () => { state.requests++; } } }); const subject = Object.assign(new Subject(), { node: { valid: true }, RankNode: null, isShow: true, careerLoadGeneration: 0, _sRankRequestId: 0, isCareerDisabledByMemoryWarning: () => state.lowMemory, }); return { subject, state, finishAction(error = null) { assert.ok(pendingAction.length, 'action_bundle must have a pending load'); if (!error) state.bundles.set(action.name, action); for (const done of pendingAction.splice(0)) done(error, error ? undefined : action); }, }; } test('cold first entry waits for action_bundle before loading rankGame and requesting ranks', () => { const { subject, state, finishAction } = fixture(); subject.getSRank(true); assert.deepEqual(state.bundleLoads, ['action_bundle']); assert.deepEqual(state.prefabLoads, []); assert.equal(state.instances, 0); assert.equal(state.requests, 0); finishAction(); assert.deepEqual(state.bundleLoads, ['action_bundle', 'career']); assert.deepEqual(state.prefabLoads, ['prefab/rankGame']); assert.equal(state.instances, 1); assert.equal(state.requests, 1); assert.deepEqual(state.errors, []); }); test('cached action_bundle allows the rank load to continue immediately', () => { const { subject, state } = fixture(true); subject.getSRank(true); assert.deepEqual(state.bundleLoads, ['career']); assert.deepEqual(state.prefabLoads, ['prefab/rankGame']); assert.equal(state.instances, 1); assert.equal(state.requests, 1); assert.deepEqual(state.errors, []); }); test('failed action_bundle load reports the error and does not continue', () => { const { subject, state, finishAction } = fixture(); subject.getSRank(true); finishAction(new Error('dependency download failed')); assert.deepEqual(state.bundleLoads, ['action_bundle']); assert.deepEqual(state.prefabLoads, []); assert.equal(state.instances, 0); assert.equal(state.requests, 0); assert.match(state.errors.join('\n'), /dependency download failed/); }); for (const [name, invalidate] of [ ['destroyed node', ({ subject }) => { subject.node.valid = false; }], ['missing node', ({ subject }) => { subject.node = null; }], ['new load generation', ({ subject }) => { subject.careerLoadGeneration++; }], ['memory warning', ({ state }) => { state.lowMemory = true; }], ]) { test(`action_bundle completion cannot continue after ${name}`, () => { const context = fixture(); context.subject.getSRank(true); invalidate(context); context.finishAction(); assert.deepEqual(context.state.bundleLoads, ['action_bundle']); assert.deepEqual(context.state.prefabLoads, []); assert.equal(context.state.instances, 0); assert.equal(context.state.requests, 0); }); } test('getSRank(false) still only preloads; showing the preloaded rank requests once', () => { const { subject, state, finishAction } = fixture(); subject.getSRank(false); assert.equal(state.requests, 0); finishAction(); assert.equal(state.instances, 1); assert.equal(state.requests, 0); subject.getSRank(true); assert.equal(state.requests, 1); assert.equal(state.instances, 1); assert.deepEqual(state.bundleLoads, ['action_bundle', 'career']); }); test('memory warning before entry prevents all rank loading', () => { const { subject, state } = fixture(); state.lowMemory = true; subject.getSRank(true); assert.deepEqual(state.bundleLoads, []); assert.equal(state.requests, 0); }); test('cached action_bundle still waits for its own declared dependencies before Career', () => { const { subject, state } = fixture(true); state.bundles.get('action_bundle').deps = ['shop']; state.dependencies.shop = ['UI']; subject.getSRank(true); assert.deepEqual(state.bundleLoads, ['shop', 'UI', 'career']); assert.equal(state.instances, 1); assert.deepEqual(state.errors, []); }); for (const owner of ['action_bundle', 'career']) { test(`${owner} dependency failure never parses or instantiates Career`, () => { const { subject, state } = fixture(true); if (owner === 'action_bundle') state.bundles.get(owner).deps = ['shop']; else state.dependencies.career = ['shop']; state.bundleErrors.shop = new Error('shop dependency offline'); subject.getSRank(true); assert.deepEqual(state.prefabLoads, []); assert.equal(state.instances, 0); assert.equal(state.requests, 0); assert.match(state.errors.join('\n'), /shop dependency offline/); }); }