import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import ts from 'typescript'; import { EventEmitter } from 'node:events'; import { resolveLaunchWaitTime } from '../../YouleNexus/assets/framework/config/remote-config.ts'; class Sprite {} class ScrollView { scrollToTop() {} stopAutoScroll() {} } class Vec3 { constructor(public x = 0, public y = 0, public z = 0) {} } class Widget { enabled = true; updateAlignment() {} } class UITransform { width = 100; height = 30; anchorX = 0.5; anchorY = 0.5; convertToWorldSpaceAR(value: Vec3) { return new Vec3(value.x + 250, value.y + 70, value.z); } convertToNodeSpaceAR(value: Vec3) { return new Vec3(value.x - 20, value.y - 10, value.z); } } class ProgressBar { progress = 0; } class Label { static Overflow = { NONE: 0 }; overflow = 1; string = ''; updateRenderData() {} } class Node extends EventEmitter { static EventType = { TOUCH_END: 'touch-end' }; active = true; children: Node[] = []; parent: Node | null = null; position = new Vec3(); setPosition(position: Vec3) { this.position = position; } components = new Map(); constructor(public name = '') { super(); } getComponent(type: unknown) { return this.components.get(type) ?? null; } getComponentsInChildren(type: unknown): any[] { const own = this.getComponent(type); return [...(own ? [own] : []), ...this.children.flatMap(child => child.getComponentsInChildren(type))]; } getChildByPath(path: string): Node | null { const [head, ...tail] = path.split('/'); const child = this.children.find(node => node.name === head); return child ? (tail.length ? child.getChildByPath(tail.join('/')) : child) : null; } } const director = new EventEmitter(); function countdownFixture() { const { createLogoCountdown } = load(); assert.equal(typeof createLogoCountdown, 'function'); let now = 0; let id = 0; const timers = new Map void }>(); const states: { visible: boolean; text: string }[] = []; const gate = createLogoCountdown({ now: () => now, schedule: (callback: () => void, ms: number) => { const key = ++id; timers.set(key, { at: now + ms, callback }); return () => timers.delete(key); }, render: (state: { visible: boolean; text: string }) => states.push(state) }); const advance = (ms: number) => { const end = now + ms; while (true) { const next = [...timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0]; if (!next) break; now = next[1].at; timers.delete(next[0]); next[1].callback(); } now = end; }; return { gate, timers, states, advance, last: () => states[states.length - 1]! }; } test('Logo countdown ticks legacy milliseconds and skip requires config, resource and socket readiness', async () => { const f = countdownFixture(); let complete = false; void f.gate.promise.then(() => { complete = true; }); assert.equal(f.last().visible, false); f.gate.skip(); f.gate.start(2500); assert.equal(f.last().text, '倒计时3s'); f.advance(1000); assert.equal(f.last().text, '倒计时2s'); f.gate.resourcesReady(); assert.equal(f.last().text, '倒计时2s 跳过'); f.gate.skip(); await Promise.resolve(); assert.equal(complete, false); f.gate.socketReady(true); await Promise.resolve(); assert.equal(complete, false, 'premature click must not queue'); f.gate.skip(); await f.gate.promise; assert.equal(f.last().visible, false); assert.equal(f.timers.size, 0); }); test('Logo exact deadline is independent from resources and socket; terminal cancellation ignores late events', async () => { const f = countdownFixture(); f.gate.start(10); f.advance(9); assert.equal(f.last().visible, true); f.advance(1); await f.gate.promise; assert.equal(f.last().visible, false); assert.equal(f.timers.size, 0); const cancelled = countdownFixture(); cancelled.gate.start(5000); const rejection = assert.rejects(cancelled.gate.promise, /cancelled/); cancelled.gate.cancel(); await rejection; const count = cancelled.states.length; cancelled.gate.start(9999); cancelled.gate.resourcesReady(); cancelled.gate.socketReady(true); cancelled.gate.skip(); cancelled.advance(10000); assert.equal(cancelled.states.length, count); assert.equal(cancelled.timers.size, 0); }); test('Logo render failure at completion rejects the gate and clears all timers', async () => { const { createLogoCountdown } = load(); let now = 0; let failRender = false; const callbacks = new Set<() => void>(); const gate = createLogoCountdown({ now: () => now, schedule: (callback: () => void) => { callbacks.add(callback); return () => callbacks.delete(callback); }, render: () => { if (failRender) throw new Error('label rendering failed'); } }); gate.start(2500); failRender = true; now = 2500; const rejection = assert.rejects(gate.promise, /label rendering failed/); assert.doesNotThrow(() => { const callback = [...callbacks][0]; callbacks.delete(callback); callback(); }); await rejection; assert.equal(callbacks.size, 0); }); test('Logo scheduling and tick render failures settle pending display promises', async () => { const { createLogoCountdown } = load(); const failed = createLogoCountdown({ now: () => 0, schedule: () => { throw new Error('timer failed'); }, render() {} }); const scheduling = assert.rejects(failed.promise, /timer failed/); assert.doesNotThrow(() => failed.start(2500)); await scheduling; let failRender = false; const callbacks = new Set<() => void>(); const gate = createLogoCountdown({ now: () => 0, schedule: (callback: () => void) => { callbacks.add(callback); return () => callbacks.delete(callback); }, render: () => { if (failRender) throw new Error('tick rendering failed'); } }); gate.start(2500); failRender = true; const rendering = assert.rejects(gate.promise, /tick rendering failed/); assert.doesNotThrow(() => { const callback = [...callbacks][1]; callbacks.delete(callback); callback(); }); await rendering; assert.equal(callbacks.size, 0); }); const source = new URL('../../YouleNexus/assets/scripts/platform-login/logo-countdown.ts', import.meta.url); function load() { assert.ok(existsSync(source), 'Cocos local login view implementation must exist'); const exports = {}; runInNewContext(ts.transpileModule(readFileSync(source, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText, { exports, require: (name: string) => { assert.equal(name, 'cc'); return { Node, Sprite, ScrollView, UITransform, Label, Widget, Vec3, director, Director: { EVENT_AFTER_DRAW: 'draw' } }; }, Error }); return exports as any; } test('Logo native binding measures label around button center and releases its touch listener', () => { const { CocosLogoCountdownView } = load(); assert.equal(typeof CocosLogoCountdownView, 'function'); const button = new Node('Spirit3103'), text = new Node('Spirit3104'), parent = new Node('group'); text.parent = parent; for (const node of [button, text, parent]) node.components.set(UITransform, new UITransform()); const label = new Label(); text.components.set(Label, label); const widget = new Widget(); text.components.set(Widget, widget); let clicks = 0; const view = new CocosLogoCountdownView(button, text, () => clicks++); assert.equal(button.active, false); assert.equal(text.active, false); view.render({ visible: true, text: '倒计时3s 跳过' }); assert.equal(label.string, '倒计时3s 跳过'); assert.equal(label.overflow, Label.Overflow.NONE); assert.equal(widget.enabled, false); assert.equal(text.position.x, 230); assert.equal(text.position.y, 60); button.emit('touch-end'); assert.equal(clicks, 1); view.dispose(); assert.equal(button.listenerCount('touch-end'), 0); assert.equal(button.active, false); });