// 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 = this.angle = 0; this.width = this.height = 100; this.anchorX = this.anchorY = 0.5; this.scaleX = this.scaleY = 1; this.opacity = 255; 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); } getContentSize() { return { width: this.width, height: this.height }; } getAnchorPoint() { return v2(this.anchorX, this.anchorY); } setContentSize(width, height) { this.width = typeof width === 'object' ? width.width : width; this.height = typeof width === 'object' ? width.height : height; } setAnchorPoint(point) { this.anchorX = point.x; this.anchorY = point.y; } setScale(x, y = x) { this.scaleX = x; this.scaleY = y; } setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; } convertToWorldSpaceAR(p) { const r = this.angle * Math.PI / 180, x = p.x * this.scaleX, y = p.y * this.scaleY; const q = v2(this.x + x * Math.cos(r) - y * Math.sin(r), this.y + x * Math.sin(r) + y * Math.cos(r)); return this.parent ? this.parent.convertToWorldSpaceAR(q) : q; } convertToNodeSpaceAR(p) { const q = this.parent ? this.parent.convertToNodeSpaceAR(p) : p; const r = -this.angle * Math.PI / 180, x = q.x - this.x, y = q.y - this.y; return v2((x * Math.cos(r) - y * Math.sin(r)) / this.scaleX, (x * Math.sin(r) + y * Math.cos(r)) / this.scaleY); } 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 {} class Button {} const enterTime = 0.5, holdTime = 0.5, castTime = 0.7, fadeTime = 0.15; const releaseTime = enterTime + holdTime; const boundaryEpsilon = 1e-10; function setup(onLand = () => {}, transform = false) { 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 }, winSize: { width: 1080, height: 1920 }, 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 RealTrail = load('assets/Script/RainbowTrail.ts', cc).default; const flights = []; class Trail extends RealTrail { constructor(...args) { super(...args); flights.push(this); } } const Opening = load('assets/Script/RainbowOldOpening.ts', cc, { './RainbowTrail': { default: Trail }, './RainbowBottleWater': { default: Water } }).default; const scene = new Node('scene'), parent = new Node('effects'), ui = new Node('ui'), bottle = new Node('bottle'), bottleBg = new Node('bottleBg'); scene.addChild(parent); scene.addChild(ui); ui.addChild(bottle); ui.addChild(bottleBg); parent.setPosition(250, 40); ui.setPosition(-300, -500); bottle.setPosition(30, 20); if (transform) { parent.setScale(0.7, 0.9); parent.angle = 12; } bottleBg.setPosition(30, 40.111); bottle.setScale(0.7); bottleBg.setScale(0.7); bottle.setContentSize(225, 169); bottleBg.setContentSize(237, 233); const sprite = bottle.addComponent(Sprite); sprite.fillRange = 0; sprite.spriteFrame = { name: 'water' }; const bgSprite = bottleBg.addComponent(Sprite); bgSprite.spriteFrame = { name: 'outline' }; bottleBg.addComponent(Button); const to = v2(160, 120), opening = new Opening(parent, bottle, to, onLand, bottleBg); const particles = () => flights[0].node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean); let frames; return { opening, flights, bottleNode: opening.bottleNode, bottle, bottleBg, sprite, parent, to, cc, get trail() { return flights[0]; }, get particles() { return particles(); }, // Capture before disposal because the actual Trail clears its private asset arrays. frames: () => frames || (frames = particles().map(p => p.spriteFrame)), 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.bottleBg.active, false); assert.equal(f.listenerCount(), 0); assert.equal(f.bottleNode.active, false); assert.equal(f.bottleNode.destroyCount, 1); if (f.trail) { assert.equal(f.trail.node.destroyCount, 1); f.frames().forEach(frame => { assert.equal(frame.destroyCount, 1); assert.equal(frame.texture.destroyCount, 1); }); } } test('temporary bottle copies only sprites, starts offscreen, and leaves bottom controls hidden', () => { const f = setup(), copies = f.bottleNode.children; assert.equal(f.bottle.active, false); assert.equal(f.bottleBg.active, false); assert.equal(f.sprite.fillRange, 0, 'do not modify bottom water sprite'); assert.equal(copies.length, 2); assert.equal(copies[0].getComponent(Sprite).spriteFrame, f.sprite.spriteFrame); assert.equal(copies[1].getComponent(Sprite).spriteFrame, f.bottleBg.getComponent(Sprite).spriteFrame); assert.equal(copies[0].getComponent(Sprite).fillRange, 1); assert.ok(copies[0].getComponent(Water)); assert.equal(copies[1].getComponent(Button), undefined); copies.forEach((copy, i) => { const original = [f.bottle, f.bottleBg][i]; assert.deepEqual(copy.getContentSize(), original.getContentSize()); assert.deepEqual(copy.getAnchorPoint(), original.getAnchorPoint()); assert.equal(copy.scaleX, original.scaleX); assert.equal(copy.scaleY, original.scaleY); assert.equal(copy.color, f.cc.Color.WHITE); }); near(copies[0].y - copies[1].y, f.bottle.y - f.bottleBg.y); const start = f.bottleNode.convertToWorldSpaceAR(v2()); near(start.x, -200); near(start.y, 880); assert.equal(f.flights.length, 0, 'do not create or emit particle systems before release'); assert.equal(f.bottleNode.active, false, 'wait for map transition readiness'); assert.equal(f.listenerCount(), 2); f.opening.dispose(); expectReleased(f); }); test('bottle enters along a curve and holds at screen center even with transformed effect parents', () => { for (const transformed of [false, true]) { const f = setup(() => {}, transformed); f.opening.update(enterTime / 2); const midway = f.bottleNode.convertToWorldSpaceAR(v2()); near(midway.x, (-200 + 2 * (540 * 0.35) + 540) / 4); near(midway.y, (880 + 2 * 1110 + 960) / 4); assert.equal(f.flights.length, 0); f.opening.update(enterTime / 2); const center = f.bottleNode.convertToWorldSpaceAR(v2()); near(center.x, 540); near(center.y, 960); f.opening.update(holdTime / 4); assert.ok(Math.abs(f.bottleNode.angle) < 7); assert.ok(f.bottleNode.scaleX > 1); assert.equal(f.flights.length, 0, 'holding bottle must not emit'); f.opening.dispose(); expectReleased(f); } }); test('release starts original RainbowTrail at the bottle center, retains B arc, and fades bottle in 0.15 seconds', () => { let lands = 0; const f = setup(() => lands++); assert.equal(f.opening.update(releaseTime), false); assert.equal(f.flights.length, 1); const from = f.parent.convertToNodeSpaceAR(v2(540, 960)); near(f.trail.node.x, from.x); near(f.trail.node.y, from.y); assert.equal(f.bottleNode.opacity, 255); f.opening.update(fadeTime / 2); assert.ok(f.bottleNode.opacity >= 127 && f.bottleNode.opacity <= 128); f.opening.update(castTime / 2 - fadeTime / 2); 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); assert.equal(f.bottleNode.active, false); f.to.x = 999; // The destination is fixed at launch. assert.equal(f.opening.update(castTime / 2 + boundaryEpsilon), false); assert.equal(lands, 1); near(f.trail.node.x, 160); near(f.trail.node.y, 120); assert.equal(f.flights.length, 1); 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(releaseTime + castTime + boundaryEpsilon), false); f.particles[0].particleCount = 0; assert.equal(f.opening.update(5), false); 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.bottleNode.x, f.bottleNode.y); assert.equal(f.opening.update(100, true), false); assert.equal(f.bottleNode.active, false); assert.equal(f.bottleNode.x, start.x); assert.equal(f.bottleNode.y, start.y); assert.equal(f.flights.length, 0); assert.equal(lands, 0); assert.equal(f.opening.update(releaseTime + castTime + boundaryEpsilon, false), false); assert.equal(lands, 1); f.particles.forEach(p => p.particleCount = 0); assert.equal(f.opening.update(100, true), false); 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(0.3); const before = v2(f.bottleNode.x, f.bottleNode.y); f.emit('hide'); assert.equal(f.bottleNode.active, false); assert.equal(f.opening.update(100), false); assert.equal(f.bottleNode.x, before.x); assert.equal(f.bottleNode.y, before.y); assert.equal(lands, 0); assert.equal(f.flights.length, 0); f.emit('show'); f.opening.update(releaseTime - 0.3 + castTime / 2); const trailBefore = v2(f.trail.node.x, f.trail.node.y); f.emit('hide'); assert.equal(f.trail.node.active, false); f.opening.update(100); assert.equal(f.trail.node.x, trailBefore.x); assert.equal(f.trail.node.y, trailBefore.y); f.emit('show'); f.opening.update(castTime / 2 + boundaryEpsilon); 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('disposal in entry, hold, or particle flight cancels reward and frees resources and event handlers', () => { for (const time of [0.3, enterTime + holdTime / 2, releaseTime + castTime / 2]) { let lands = 0; const f = setup(() => lands++); f.opening.update(time); f.opening.dispose(); f.opening.dispose(); expectReleased(f); f.emit('show'); assert.equal(f.bottleNode.active, false); assert.equal(f.opening.update(100), true); assert.equal(lands, 0); } }); test('large frame gaps cross entry, hold, and cast once without losing the elapsed remainder', () => { let lands = 0; const f = setup(() => lands++); f.opening.update(100); assert.equal(f.flights.length, 1); assert.equal(lands, 1); assert.equal(f.bottleNode.opacity, 0); near(f.trail.node.x, f.to.x); near(f.trail.node.y, f.to.y); f.opening.update(100); assert.equal(lands, 1); assert.equal(f.flights.length, 1); f.opening.dispose(); expectReleased(f); }); 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(releaseTime + castTime + boundaryEpsilon), 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); });