Files
MatchMaster/tools/test-shared-popup-backdrop.cjs

443 lines
27 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const ts = require('typescript');
const path = require('node:path');
function fixture() {
class Component { get enabledInHierarchy() { return this.enabled !== false && this.node.activeInHierarchy; } }
class RenderComponent extends Component { enabled = true; }
class Graphics extends RenderComponent {
points = [];
clear() { this.points = []; }
moveTo(x, y) { this.points.push([x, y]); }
lineTo(x, y) { this.points.push([x, y]); }
close() {} fill() { this.paintedAlpha = this.fillColor.a; }
}
class Sprite extends RenderComponent {}
class BlockInputEvents extends Component {}
class Label extends RenderComponent {}
class Node {
constructor(name) {
this.name = name; this.children = []; this.components = []; this.handlers = {};
this.active = true; this.opacity = 255; this.zIndex = 0;
this.width = 1080; this.height = 1920; this.anchorX = this.anchorY = .5;
this.x = this.y = 0; this.scaleX = this.scaleY = 1;
}
get activeInHierarchy() { return !this.destroyed && this.active && (!this.parent || this.parent.activeInHierarchy); }
addChild(node, z = 0) { node.parent = this; node.zIndex = z; this.children.push(node); }
getSiblingIndex() { return this.parent ? this.parent.children.indexOf(this) : 0; }
getComponent(Type) { return this.components.find(c => typeof Type === 'string' ? c.constructor.name === Type : c instanceof Type) || null; }
getComponents(Type) { return this.components.filter(c => c instanceof Type); }
addComponent(Type) { const c = new Type(); c.node = this; this.components.push(c); return c; }
getChildByName(name) { return this.children.find(n => n.name === name) || null; }
setContentSize(w, h) { this.width = typeof w === 'object' ? w.width : w; this.height = typeof w === 'object' ? w.height : h; }
getContentSize() { return { width: this.width, height: this.height }; }
convertToWorldSpaceAR(p) {
const point = { x: p.x * this.scaleX + this.x, y: p.y * this.scaleY + this.y };
return this.parent ? this.parent.convertToWorldSpaceAR(point) : point;
}
convertToNodeSpaceAR(p) {
const point = this.parent ? this.parent.convertToNodeSpaceAR(p) : p;
return { x: (point.x - this.x) / this.scaleX, y: (point.y - this.y) / this.scaleY };
}
on(event, fn) { this.handlers[event] = fn; }
stopAllActions() {}
destroy() { this.destroyed = true; }
}
Node.EventType = { TOUCH_END: 'touchend' };
const cc = { Node, Component, RenderComponent, Sprite, Graphics, Label, BlockInputEvents,
_decorator: { ccclass: c => c }, macro: { MAX_ZINDEX: 32767 }, Color: { BLACK: { r: 0, g: 0, b: 0, a: 255 } },
color: (r, g, b, a = 255) => ({ r, g, b, a, _val: ((a << 24) | (b << 16) | (g << 8) | r) >>> 0 }),
isValid: n => !!n && !n.destroyed, v2: (x, y) => ({ x, y }), warn() {}, error() {} };
const load = (file, deps = {}) => {
const exports = {};
const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { target: 7, module: 1, experimentalDecorators: true } }).outputText;
new Function('exports', 'require', 'cc', code)(exports, name => {
assert.ok(name in deps, 'unexpected dependency ' + name); return deps[name];
}, cc);
return exports;
};
const runningTweens = new Set();
cc.Tween = { stopAllByTarget: target => runningTweens.delete(target) };
cc.tween = target => ({ delay() { return this; }, to() { return this; }, call() { return this; }, start() { runningTweens.add(target); } });
cc.instantiate = node => {
const copy = new Node(node.name);
for (const key of ['x', 'y', 'scaleX', 'scaleY', 'opacity', 'active', 'width', 'height']) copy[key] = node[key];
node.components.forEach(c => { const props = { ...c }; delete props.node; Object.assign(copy.addComponent(c.constructor), props); });
node.children.forEach(child => copy.addChild(cc.instantiate(child)));
return copy;
};
const animationModule = load('assets/Script/LoadingCatAnimation.ts');
const managerModule = load('assets/Script/SharedPopupBackdrop.ts', { './LoadingCatAnimation': animationModule });
const Source = load('assets/Script/PopupBackdropSource.ts', { './SharedPopupBackdrop': managerModule }).default;
const canvas = new Node('Canvas'), manager = managerModule.default.get(canvas);
const template = new Node('Loading'); template.active = false; canvas.addChild(template);
const scene = JSON.parse(fs.readFileSync('assets/Scene/HomeScene.fire'));
const authored = scene.find(n => n.__type__ === 'cc.Node' && n._name === 'Loading' && scene[n._parent?.__id__]?._name === 'Canvas');
const fromScene = data => {
const node = new Node(data._name); node.x = data._trs.array[0]; node.y = data._trs.array[1]; node.opacity = data._opacity;
for (const ref of data._components) {
const c = scene[ref.__id__];
if (c.__type__ === 'cc.Sprite') node.addComponent(Sprite).spriteFrame = c._spriteFrame;
if (c.__type__ === 'cc.Label') Object.assign(node.addComponent(Label), { string: c._string, fontSize: c._fontSize });
}
data._children.forEach(ref => node.addChild(fromScene(scene[ref.__id__])));
return node;
};
authored._children.forEach(ref => template.addChild(fromScene(scene[ref.__id__])));
function popup(name, parent = canvas, z = 0) {
const root = new Node(name); parent.addChild(root, z);
const mask = new Node('mask'); root.addChild(mask); mask.opacity = 150;
const sprite = mask.addComponent(Sprite), input = mask.addComponent(BlockInputEvents);
mask.on('touchend', () => {});
const source = mask.addComponent(Source); source.onLoad(); source.onEnable();
return { root, mask, source, sprite, input, visual: source.source.visual,
close() { root.active = false; source.onDisable(); },
open() { root.active = true; source.onEnable(); } };
}
return { cc, Node, canvas, manager, managerModule, Source, load, popup, template, runningTweens, alpha: node => node.getComponent(Graphics).paintedAlpha };
}
test('A closes before B finishes loading: every rendered frame has one equally dark backdrop', () => {
const f = fixture(), owner = {};
f.manager.hold(owner, () => {});
const a = f.popup('A'); f.manager.lateUpdate(.016);
assert.equal(a.visual.active, true); assert.equal(a.sprite.enabled, false);
assert.equal(f.alpha(a.visual), 200); assert.equal(f.manager.bridge.active, false);
a.close();
assert.equal(f.manager.bridge.active, true, 'bridge takes over synchronously, before next queue update');
for (let frame = 0; frame < 80; frame++) {
f.manager.lateUpdate(.016);
assert.equal(f.manager.bridge.active, true); assert.equal(f.alpha(f.manager.bridge), 200);
}
assert.equal(f.manager.message.active, true);
const b = f.popup('B'); f.manager.lateUpdate(.016);
assert.equal(f.manager.bridge.active, false); assert.equal(b.visual.active, true);
assert.equal(f.alpha(b.visual), 200); assert.equal(a.visual.active, false);
f.manager.release(owner); assert.equal(b.visual.active, true, 'releasing queue ownership cannot hide a manual/visible modal');
b.close(); f.manager.lateUpdate(.075);
assert.ok(f.alpha(f.manager.bridge) > 0 && f.alpha(f.manager.bridge) < 200);
f.manager.lateUpdate(.08); assert.equal(f.manager.bridge.active, false);
});
test('nested confirmation dims its parent without stacking masks or changing event bindings', () => {
const f = fixture(), parent = f.popup('Activity');
const handler = parent.mask.handlers.touchend;
const confirmation = f.popup('Confirmation', parent.root);
f.manager.lateUpdate(.016);
assert.equal(parent.visual.active, false); assert.equal(confirmation.visual.active, true);
assert.equal(parent.input.node, parent.mask); assert.equal(parent.mask.handlers.touchend, handler);
confirmation.close(); f.manager.lateUpdate(.016);
assert.equal(parent.visual.active, true); assert.equal(confirmation.visual.active, false);
assert.equal(f.manager.bridge, null, 'no scene bridge needed for nested handoff');
});
test('shared opacity matches the original Home Loading mask, which also has an adapter', () => {
const f = fixture(), scene = JSON.parse(fs.readFileSync('assets/Scene/HomeScene.fire'));
const loading = scene.find(n => n.__type__ === 'cc.Node' && n._name === 'Loading' && scene[n._parent?.__id__]?._name === 'Canvas');
const mask = loading._children.map(ref => scene[ref.__id__]).find(n => n._name === 'New Sprite(Splash)');
assert.equal(mask._opacity, 200);
assert.equal(f.managerModule.POPUP_BACKDROP_OPACITY, mask._opacity);
assert.ok(mask._components.some(ref => scene[ref.__id__].__type__ === '613aechc9tHfZ76dlmB0WQb'));
});
test('Gold Miner mounts old and new remote prefabs with exactly one managed mask', () => {
for (const serialized of [false, true]) {
const f = fixture();
f.cc.instantiate = prefab => prefab;
const Host = f.load('assets/Script/coin_madness/CoinMadnessHost.ts', {
'../Sdk/MiniGameSdk': {}, '../../home_popup_queue/HomePopupQueue': {},
'./GoldMinerService': {}, '../module/Pay/Utils': {}, '../module/Config/ActivityBundleLoader': {},
'../PopupBackdropSource': { default: f.Source }
}).default;
const host = f.canvas.addComponent(Host);
const mounted = [];
for (const [name, maskName] of [['CoinMadness', 'Dim'], ['CoinMadnessSettlement', 'Dim'], ['CoinMadnessHelp', 'Mask']]) {
const root = new f.Node(name), mask = new f.Node(maskName);
root.addChild(mask); mask.opacity = maskName === 'Dim' ? 175 : 190;
const sprite = mask.addComponent(f.cc.Sprite);
const handler = () => {}; mask.on('touchend', handler);
// Emulate Creator lifecycle: serialized components enable on mounting;
// newly added components enable immediately on an active scene node.
if (serialized) { const source = mask.addComponent(f.Source); source.onLoad(); }
const add = mask.addComponent.bind(mask);
mask.addComponent = Type => { const c = add(Type); c.onLoad(); c.onEnable(); return c; };
const mount = f.canvas.addChild.bind(f.canvas);
f.canvas.addChild = (node, z) => {
mount(node, z);
const source = mask.getComponent(f.Source); if (source) source.onEnable();
};
assert.equal(host.mountPopup(root, maskName, name.endsWith('Help') ? 100 : 99), root);
f.canvas.addChild = mount;
f.manager.lateUpdate(.016);
const sources = mask.getComponents(f.Source);
assert.equal(sources.length, 1);
assert.equal(sprite.enabled, false, 'the old Sprite must not stack with the managed backdrop');
assert.equal(mask.handlers.touchend, handler);
assert.equal(f.alpha(sources[0].source.visual), 200);
assert.equal(sources[0].source.visual.active, true);
assert.equal(f.manager.visibleSources().filter(s => s.visual.active).length, 1);
mounted.push({ root, source: sources[0] });
}
const help = mounted.pop(); help.root.active = false; help.source.onDisable();
assert.equal(mounted[1].source.source.visual.active, true, 'closing help restores the underlying managed mask');
}
});
test('topmost sibling wins by zIndex, including a popup opened after a higher reward', () => {
const f = fixture(), high = f.popup('Reward', f.canvas, 1001), low = f.popup('Activity', f.canvas, 2);
f.manager.lateUpdate(.016);
assert.equal(high.visual.active, true); assert.equal(low.visual.active, false);
high.close(); assert.equal(low.visual.active, true);
high.open(); assert.equal(high.visual.active, true); assert.equal(low.visual.active, false);
});
test('loading, legacy mask, queued bridge and activity never darken the screen together', () => {
const f = fixture(), owner = {};
const loading = f.popup('Loading', f.canvas, f.cc.macro.MAX_ZINDEX);
const countLayers = () => {
let layers = 0;
const visit = node => {
if (!node.activeInHierarchy) return;
for (const c of node.components) {
if (c.enabled === false) continue;
if (c instanceof f.cc.Sprite || c instanceof f.cc.Graphics && c.paintedAlpha > 0) layers++;
}
node.children.forEach(visit);
};
visit(f.canvas); return layers;
};
const check = () => {
assert.equal(countLayers(), 1, 'exactly one rendered backdrop, including original Sprite masks');
const active = f.manager.visibleSources().find(s => s.visual.active);
const visual = active ? active.visual : f.manager.bridge;
assert.equal(f.alpha(visual), 200);
assert.equal(visual.opacity, 255, 'Canvas must not multiply fill alpha by another node opacity');
};
f.manager.hold(owner, () => {}); check();
loading.close(); check(); // The bridge covers asynchronous mounting.
const main = f.popup('CoinMadness', f.canvas, f.cc.macro.MAX_ZINDEX - 1); check();
loading.open(); check(); // A second request can show Loading above the existing activity.
assert.equal(main.visual.active, false);
loading.close(); check();
const help = f.popup('Help', f.canvas, f.cc.macro.MAX_ZINDEX); check();
help.close(); check();
main.close(); check();
f.manager.release(owner); f.manager.lateUpdate(.075);
assert.equal(f.alpha(f.manager.bridge), 100, 'fade updates the drawn alpha in the same frame');
assert.equal(countLayers(), 1);
f.manager.lateUpdate(.08); assert.equal(countLayers(), 0);
});
const creator = process.env.COCOS_CREATOR_PATH || 'C:/ProgramData/cocos/editors/Creator/2.4.15';
const renderer = path.join(creator, 'resources/engine/cocos2d/core/renderer');
test('Creator WebGL receives alpha 200 for managed masks and 100 halfway through the fade', {
skip: !fs.existsSync(path.join(renderer, 'assembler.js')) && 'set COCOS_CREATOR_PATH to run the engine check'
}, () => {
const f = fixture();
f.cc.gfx = { VertexFormat: class {} };
const base = f.load(path.join(renderer, 'assembler.js'), { './webgl/vertex-format': {}, './assembler-pool': {} });
const GraphicsAssembler = f.load(path.join(renderer, 'webgl/assemblers/graphics/index.js'), {
'../../../assembler': base, '../../../../../renderer/core/input-assembler': {}, '../../mesh-buffer': {},
'../../../index': {}, '../../../../graphics/graphics': { LineJoin: {}, LineCap: {} },
'../../../../graphics/types': { PointFlags: {} }, './earcut': {}, './impl': {}
}).default;
// Execute the installed engine's real fill/vertex-color methods; geometry allocation is irrelevant to alpha.
const vertexAlpha = node => {
const assembler = new GraphicsAssembler(), data = new ArrayBuffer(16);
assembler._buffer = { vertexStart: 0, meshbuffer: { _vData: new Float32Array(data), _uintVData: new Uint32Array(data) } };
assembler._expandFill = function () { this._vset(0, 0); };
assembler.fill({ node, _fillColor: node.getComponent(f.cc.Graphics).fillColor, _impl: {} });
return assembler._buffer.meshbuffer._uintVData[2] >>> 24;
};
const popup = f.popup('CoinMadness');
assert.equal(vertexAlpha(popup.visual), 200);
popup.close(); assert.equal(vertexAlpha(f.manager.bridge), 200);
f.manager.lateUpdate(.075); assert.equal(vertexAlpha(f.manager.bridge), 100);
f.manager.lateUpdate(.08); assert.equal(vertexAlpha(f.manager.bridge), 0);
});
test('backdrop covers the canvas inside translated/scaled parents and after viewport resize', () => {
const f = fixture(), p = f.popup('Scaled');
p.root.scaleX = .5; p.root.scaleY = .4; p.root.x = 70; p.root.y = -20;
f.canvas.width = 1200; f.canvas.height = 2400;
f.manager.lateUpdate(.016);
const points = p.visual.getComponent(f.cc.Graphics).points.map(([x, y]) => p.mask.convertToWorldSpaceAR({ x, y }));
assert.deepEqual(points, [{ x: -600, y: -1200 }, { x: 600, y: -1200 }, { x: 600, y: 1200 }, { x: -600, y: 1200 }]);
});
test('slow loading has feedback, cancellation releases the input shield, and stale clicks do nothing', () => {
const f = fixture(), owner = {}; let cancelled = 0;
f.manager.hold(owner, () => { cancelled++; f.manager.release(owner); });
f.manager.lateUpdate(.7); assert.equal(f.manager.message.active, false);
f.manager.lateUpdate(.2); assert.equal(f.manager.message.active, true);
f.manager.lateUpdate(10); assert.equal(f.manager.cancelButton.active, true);
assert.ok(f.manager.bridge.getComponent(f.cc.BlockInputEvents));
f.manager.cancelButton.handlers.touchend({ stopPropagation() {} });
assert.equal(cancelled, 1); f.manager.lateUpdate(.2); assert.equal(f.manager.bridge.active, false);
f.manager.hold(owner, () => { cancelled++; }); f.manager.lateUpdate(11);
f.popup('NowReady'); f.manager.cancelButton.handlers.touchend({ stopPropagation() {} });
assert.equal(cancelled, 1);
});
test('bridge reuses authored cat loading content without its mask and stops animation on handoff', () => {
const f = fixture(), owner = {};
f.manager.hold(owner, () => f.manager.release(owner));
f.manager.lateUpdate(.9);
const content = f.manager.message, cat = content.getChildByName('cat');
assert.equal(content.active, true);
assert.deepEqual(content.children.map(n => n.name), ['New Label', 'block1', 'block2', 'block3', 'block4', 'cat']);
for (const node of content.children) {
const authored = f.template.getChildByName(node.name);
assert.notEqual(node, authored);
assert.equal(node.x, authored.x); assert.equal(node.y, authored.y);
if (node.getComponent(f.cc.Sprite)) assert.equal(node.getComponent(f.cc.Sprite).spriteFrame, authored.getComponent(f.cc.Sprite).spriteFrame);
}
assert.equal(content.getChildByName('New Label').getComponent(f.cc.Label).string, '加载中,请稍等喵~');
assert.ok(f.manager.cancelButton.y < content.getChildByName('New Label').y - 70);
assert.equal(f.template.active, false, 'the separate home Loading is not opened');
assert.ok(f.runningTweens.has(cat));
const runId = content.__loadingCatAnimationState.runId;
f.manager.lateUpdate(.1);
assert.equal(content.__loadingCatAnimationState.runId, runId, 'do not restart animation every frame');
const activity = f.popup('Activity');
assert.equal(content.active, false); assert.equal(f.runningTweens.has(cat), false);
activity.close(); f.manager.lateUpdate(.9);
assert.ok(f.runningTweens.has(cat));
f.manager.release(owner);
assert.equal(content.active, false); assert.equal(f.runningTweens.has(cat), false);
f.manager.hold(owner, () => {}); f.manager.lateUpdate(.9); f.manager.onDestroy();
assert.equal(f.runningTweens.has(cat), false);
});
test('the dynamically built jungle panel dismisses queue loading and hands the backdrop back on close', () => {
const f = fixture(), nodes = [], owner = {};
f.cc.Node = class extends f.Node {
constructor(name) { super(name); nodes.push(this); }
setScale(value) { this.scaleX = this.scaleY = value; }
setPosition() {}
};
f.cc.Graphics.prototype.rect = function () {};
const View = f.load('assets/jungle_treasure/script/JungleView.ts', {
'../../Script/NumberToImage': { default: class {} },
'./JungleTitleShine': { default: class {} },
'../../Script/PopupBackdropSource': { default: f.Source }
}).default;
const root = new f.cc.Node('jungle'); f.canvas.addChild(root);
const view = new View(root, {}, {}, () => {}, () => {});
// Keep the production build path and mask creation; unrelated card artwork is stubbed.
view.stretchToParent = () => {};
view.createSpriteNode = name => new f.cc.Node(name);
view.createCloseButton = () => new f.cc.Node('close');
view.createCard = () => new f.cc.Node('card');
view.createLabel = node => ({ node });
f.manager.hold(owner, () => f.manager.release(owner));
f.manager.lateUpdate(11);
assert.equal(f.manager.message.active, true);
assert.equal(f.manager.cancelButton.active, true);
view.build();
const shade = nodes.find(node => node.name === 'shade');
const source = shade.getComponent(f.Source);
assert.ok(source, 'the runtime-created main mask must register as a visible popup');
// The fixture invokes the component lifecycle that Creator runs on activation.
source.onLoad(); source.onEnable();
f.manager.setWaiting(owner, true); f.manager.lateUpdate(11);
assert.equal(f.manager.bridge.active, false);
assert.equal(f.manager.message.active, false);
assert.equal(shade.getComponent(f.cc.Graphics).enabled, false, 'avoid two dim masks');
assert.equal(source.source.visual.active, true);
root.active = false; source.onDisable(); f.manager.lateUpdate(.9);
assert.equal(f.manager.message.active, true, 'the next queued popup can still show loading');
f.manager.release(owner); f.manager.lateUpdate(.2);
assert.equal(f.manager.bridge.active, false);
});
test('scene cleanup drops leases and visuals; another scene gets a fresh manager', () => {
const f = fixture(); f.manager.hold({}, () => assert.fail('cleanup is not user cancellation'));
const p = f.popup('A');
const node = f.manager.bridge; f.manager.onDestroy();
assert.equal(node.destroyed, true); assert.equal(p.visual.active, false); assert.equal(f.manager.holds.size, 0);
p.close(); f.manager.refresh(); assert.equal(f.manager.bridge, null);
const other = new f.Node('Canvas'); assert.notEqual(f.managerModule.default.get(other), f.manager);
});
test('queue retains the backdrop across failed/skipped tasks and ignores late callbacks after cancellation', () => {
const f = fixture(), policyModule = f.load('assets/home_popup_queue/HomePopupPolicy.ts');
f.cc.fx = { GameConfig: { GM_INFO: { uid: 'one', token: 'token', level: 80 }, getNewPlayerHomeLevel: () => 15,
isGameplayEntryPreparing: () => false }, StorageMessage: { getStorage: () => null, setStorage() {} } };
const Queue = f.load('assets/home_popup_queue/HomePopupQueue.ts', {
'./HomePopupPolicy': policyModule, '../Script/SharedPopupBackdrop': f.managerModule,
'../Script/module/Config/ActivityScheduleService': { default: { now: () => Date.now() } },
'../Script/module/Pay/Utils': { default: { apiBaseUrl: () => 'test' } }
}).default;
const queue = f.canvas.addComponent(Queue); queue.onLoad(); queue.sources.clear();
let delayed, cleanup = 0;
queue.enqueue('failed', 0, done => done()); queue.enqueue('next', 1, done => {
delayed = done; return () => { cleanup++; };
});
queue.update(); f.manager.lateUpdate(.016); assert.equal(f.manager.bridge.active, true);
queue.update(); assert.equal(queue.running, 'next'); assert.equal(f.alpha(f.manager.bridge), 200);
class CoinMadnessHost extends f.cc.Component { isPopupActive() { return true; } }
f.canvas.addComponent(CoinMadnessHost);
queue.update(); f.manager.lateUpdate(.9);
assert.equal(f.manager.bridge.active, true, 'a loading flag is not an already-visible popup');
assert.equal(f.manager.message.active, true);
queue.cancelBatch(); delayed(); f.manager.lateUpdate(.2);
assert.equal(queue.running, ''); assert.equal(f.manager.bridge.active, false);
assert.equal(queue.canPresent('next'), false);
queue.cancelBatch(); assert.equal(cleanup, 1, 'loading input shields are cleaned up exactly once');
});
for (const cancel of [false, true]) test(`manual reward handoff retains the backdrop during a cold load; cancel=${cancel}`, async () => {
const f = fixture(), a = f.popup('Activity');
f.cc.fx = { GameConfig: { isGameplayEntryPreparing: () => false } };
const ast = ts.createSourceFile('JiaZai.ts', fs.readFileSync('assets/Script/JiaZai.ts', 'utf8'), 99, true);
const cls = ast.statements.find(ts.isClassDeclaration);
const method = cls.members.find(m => m.name?.getText(ast) === 'openRewardWindow').getText(ast);
const code = ts.transpileModule(`class Subject { ${method} }; return Subject;`, { compilerOptions: { target: 7 } }).outputText;
const Subject = new Function('cc', 'JiaZai', 'HomePopupQueue', 'SharedPopupBackdrop', code)(f.cc,
{ cachedRewardPrefab: null }, { get: () => ({ has: () => false }) }, f.managerModule.default);
const home = new Subject(); home.node = f.canvas; home.isHomeRuntimeAlive = () => true;
let ready, closed = 0, opened = 0;
const request = new Promise(resolve => ready = resolve);
home.loadHomePopupPrefab = () => request;
home.openRewardWindow([{ type: 'coin', count: 10 }], undefined, () => closed++);
a.close(); f.manager.lateUpdate(11);
assert.equal(f.manager.bridge.active, true);
if (cancel) f.manager.cancelButton.handlers.touchend({ stopPropagation() {} });
home.openRewardWindow = () => { opened++; f.popup('Reward'); };
ready(); await request; await Promise.resolve();
assert.equal(opened, cancel ? 0 : 1); assert.equal(closed, cancel ? 1 : 0);
assert.equal(f.manager.holds.size, 0);
f.manager.lateUpdate(.2); assert.equal(f.manager.bridge.active, false);
});
test('activity prefab adapters cover dim masks while preserving original render and input components', () => {
const uuid = JSON.parse(fs.readFileSync('assets/Script/PopupBackdropSource.ts.meta')).uuid;
const h = uuid.replaceAll('-', ''), alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let id = h.slice(0, 5); for (let i = 5; i < 32; i += 3) { const n = parseInt(h.slice(i, i + 3), 16); id += alphabet[n >> 6] + alphabet[n & 63]; }
const folders = ['action_bundle', 'shop', 'passCheck', 'cloud_rise_home', 'coin_madness', 'pyramid',
'seven_day_gift', 'jungle_treasure', 'mini_program_benefits', 'publicUI'];
const files = folders.flatMap(folder => fs.readdirSync(`assets/${folder}/prefab`).filter(file => file.endsWith('.prefab'))
.map(file => path.join('assets', folder, 'prefab', file)))
.concat(['stage1', 'stage2', 'stage3'].map(stage => `assets/cloud_rise/${stage}/prefab/CloudRise.prefab`), 'assets/Scene/HomeScene.fire');
let boundFiles = 0;
for (const file of files) {
const current = JSON.parse(fs.readFileSync(file));
const components = current.map((item, i) => item.__type__ === id ? i : -1).filter(i => i >= 0);
if (components.length) boundFiles++;
for (const index of components) {
const mask = current[current[index].node.__id__];
assert.ok(mask._color.r < 40 && mask._color.g < 40 && mask._color.b < 40, file + ': only dim backgrounds');
assert.ok(mask._components.some(ref => ['cc.Sprite', 'cc.Graphics'].includes(current[ref.__id__].__type__)), file);
if (current[mask._parent?.__id__]?._name === 'Loading') {
assert.equal(file, 'assets/Scene/HomeScene.fire');
assert.equal(mask._name, 'New Sprite(Splash)', 'only the backdrop, never the loading artwork');
}
assert.ok(mask._components.some(ref => ref.__id__ === index));
}
}
assert.equal(boundFiles, 29);
});