// TYPESCRIPT_PATH may point to the TypeScript bundled with the local editor. 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 v2 = (x = 0, y = 0) => ({ x, y }); function load(file, cc, deps = {}) { const mod = { exports: {} }; vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, file), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true } }).outputText, { cc, module: mod, exports: mod.exports, require: name => { assert.ok(deps[name], name); return deps[name]; } }); return mod.exports; } class Node { constructor(name) { this.name = name; this.x = this.y = 0; this.active = true; this.components = new Map(); this.children = []; this.destroyCount = 0; } addComponent(type) { const c = new type(); c.node = this; this.components.set(type, c); return c; } getComponent(type) { return this.components.get(type); } addChild(child) { child.parent = this; this.children.push(child); } setContentSize(width, height) { this.width = width; this.height = height; } setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; } convertToWorldSpaceAR(p) { const q = v2(this.x + p.x, this.y + p.y); return this.parent ? this.parent.convertToWorldSpaceAR(q) : q; } convertToNodeSpaceAR(p) { const world = this.convertToWorldSpaceAR(v2()); return v2(p.x - world.x, p.y - world.y); } destroy() { this.destroyed = true; this.destroyCount++; } } class Sprite {} Sprite.SizeMode = { CUSTOM: 0 }; class ParticleSystem { constructor() { this.particleCount = 4; this.resetCount = 0; this.stopCount = 0; } resetSystem() { this.resetCount++; } stopSystem() { this.stopCount++; } } ParticleSystem.PositionType = { FREE: 0 }; ParticleSystem.EmitterMode = { GRAVITY: 0 }; class Texture2D { constructor() { this.destroyCount = 0; } initWithData(pixels, format, width, height) { this.width = width; this.height = height; } destroy() { this.destroyCount++; } } Texture2D.PixelFormat = { RGBA8888: 0 }; class SpriteFrame { constructor(texture) { this.texture = texture; this.destroyCount = 0; } destroy() { this.destroyCount++; } } class Water {} const rules = load('assets/Script/RainbowRules.ts', {}); function setup(onLand = () => {}) { const events = new Map(), listeners = name => events.get(name) || new Set(); const cc = { Node, Sprite, ParticleSystem, Texture2D, SpriteFrame, v2, Color: { WHITE: { r: 255, g: 255, b: 255 } }, color: (r, g, b, a = 255) => ({ r, g, b, a }), macro: { MAX_ZINDEX: 1000, SRC_ALPHA: 1, ONE_MINUS_SRC_ALPHA: 2 }, isValid: node => !!node && !node.destroyed, game: { EVENT_HIDE: 'hide', EVENT_SHOW: 'show', on(name, callback) { const set = listeners(name); set.add(callback); events.set(name, set); }, off(name, callback) { listeners(name).delete(callback); } } }; const Trail = load('assets/Script/RainbowTrail.ts', cc).default; const Opening = load('assets/Script/RainbowOldOpening.ts', cc, { './RainbowTrail': { default: Trail }, './RainbowBottleWater': { default: Water }, './RainbowRules': rules }).default; const scene = new Node('scene'), parent = new Node('effects'), ui = new Node('ui'), bottle = new Node('bottle'); scene.addChild(parent); scene.addChild(ui); ui.addChild(bottle); parent.setPosition(250, 40); ui.setPosition(-300, -500); bottle.setPosition(30, 20); const sprite = bottle.addComponent(Sprite); sprite.fillRange = 0; bottle.active = false; const to = v2(160, 120), opening = new Opening(parent, bottle, to, onLand), trail = opening.flight; const particles = trail.node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean); const frames = particles.map(p => p.spriteFrame), textures = frames.map(f => f.texture); return { opening, trail, particles, frames, textures, bottle, sprite, parent, to, cc, emit: name => [...listeners(name)].forEach(fn => fn()), listenerCount: () => [...events.values()].reduce((n, set) => n + set.size, 0) }; } function near(actual, expected) { assert.ok(Math.abs(actual - expected) < 1e-8, `${actual} != ${expected}`); } function expectReleased(f) { assert.equal(f.bottle.active, false); assert.equal(f.listenerCount(), 0); assert.equal(f.trail.node.destroyCount, 1); [...f.frames, ...f.textures].forEach(asset => assert.equal(asset.destroyCount, 1)); } test('C opening shows full white animated bottle and follows B flight path from bottle world coordinates', () => { let lands = 0; const f = setup(() => lands++); assert.equal(f.bottle.active, true); assert.equal(f.sprite.fillRange, 1); assert.equal(f.bottle.color, f.cc.Color.WHITE); assert.ok(f.bottle.getComponent(Water)); assert.equal(f.listenerCount(), 2); assert.equal(f.trail.node.active, false, 'await map transition readiness before emitting'); const from = f.parent.convertToNodeSpaceAR(f.bottle.convertToWorldSpaceAR(v2())); assert.equal(f.trail.node.x, from.x); assert.equal(f.trail.node.y, from.y); assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS / 2), false); const top = Math.max(from.y, f.to.y) + 180; const controlY = top + Math.sqrt((top - from.y) * (top - f.to.y)); near(f.trail.node.x, f.to.x + (from.x - f.to.x) / 4); near(f.trail.node.y, from.y / 4 + controlY / 2 + f.to.y / 4); assert.equal(lands, 0); f.to.x = 999; // The destination is fixed at launch. assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS / 2), false); assert.equal(lands, 1); near(f.trail.node.x, 160); near(f.trail.node.y, 120); assert.equal(f.bottle.active, true, 'tail still visible: map must remain locked'); assert.equal(f.trail.node.children.find(n => n.name === 'rainbowHead').active, false); f.particles.forEach(p => assert.equal(p.stopCount, 1)); f.opening.dispose(); expectReleased(f); }); test('completion waits for both real RainbowTrail particle systems, then cleans up once', () => { let lands = 0; const f = setup(() => lands++); assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS), false); f.particles[0].particleCount = 0; assert.equal(f.opening.update(5), false); assert.equal(f.bottle.active, true); f.particles[1].particleCount = 0; assert.equal(f.opening.update(0), true); expectReleased(f); assert.equal(f.opening.update(100), true); f.opening.dispose(); assert.equal(lands, 1); expectReleased(f); }); test('map transition pause holds launch and tail completion until explicitly resumed', () => { let lands = 0; const f = setup(() => lands++); const start = v2(f.trail.node.x, f.trail.node.y); assert.equal(f.opening.update(100, true), false); assert.equal(f.trail.node.active, false); assert.equal(f.trail.node.x, start.x); assert.equal(f.trail.node.y, start.y); assert.equal(lands, 0); assert.equal(f.bottle.active, true); assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS, false), false); assert.equal(lands, 1); f.particles.forEach(p => p.particleCount = 0); assert.equal(f.opening.update(100, true), false); assert.equal(f.bottle.active, true); assert.equal(f.trail.node.destroyCount, 0); assert.equal(f.opening.update(0, false), true); expectReleased(f); }); test('hide/show pauses opening and its fading trail without depending on gameStart or restarting particles', () => { let lands = 0; const f = setup(() => lands++); f.opening.update(rules.RAINBOW_CAST_SECONDS / 3); const before = v2(f.trail.node.x, f.trail.node.y); f.emit('hide'); assert.equal(f.trail.node.active, false); assert.equal(f.bottle.active, false); assert.equal(f.opening.update(100), false); assert.equal(f.trail.node.x, before.x); assert.equal(f.trail.node.y, before.y); assert.equal(lands, 0); f.emit('show'); assert.equal(f.bottle.active, true); f.opening.update(rules.RAINBOW_CAST_SECONDS); assert.equal(lands, 1); assert.equal(f.trail.node.active, true); f.particles.forEach(p => assert.equal(p.resetCount, 1)); f.emit('hide'); f.particles.forEach(p => p.particleCount = 0); assert.equal(f.opening.update(10), false, 'do not end the animation while hidden'); f.emit('show'); assert.equal(f.opening.update(0), true); expectReleased(f); }); test('dispose before landing cancels the reward and frees resources and event handlers', () => { let lands = 0; const f = setup(() => lands++); f.opening.update(rules.RAINBOW_CAST_SECONDS / 2); f.opening.dispose(); f.opening.dispose(); expectReleased(f); f.emit('show'); assert.equal(f.bottle.active, false); assert.equal(f.opening.update(100), true); assert.equal(lands, 0); }); test('a landing failure propagates once and remains safe for caller disposal or later updates', () => { let lands = 0; const failure = new Error('apply failed'); const f = setup(() => { lands++; throw failure; }); assert.throws(() => f.opening.update(rules.RAINBOW_CAST_SECONDS), error => error === failure); assert.equal(f.opening.update(1), false); assert.equal(lands, 1); f.particles.forEach(p => assert.equal(p.stopCount, 1)); f.opening.dispose(); expectReleased(f); });