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'; class Sprite {} class UITransform {} class ProgressBar { progress = 0; } class Label { string = ''; } class Node extends EventEmitter { static EventType = { TOUCH_END: 'touch-end' }; active = true; children: Node[] = []; components = new Map(); constructor(public name = '') { super(); } getComponent(type: unknown) { return this.components.get(type) ?? null; } 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(); const source = new URL('../../YouleNexus/assets/scripts/local-platform-login/local-login-view.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, UITransform, Label, director, Director: { EVENT_AFTER_DRAW: 'draw' } }; }, Error }); return exports as any; } function fixture() { const refs = { loadingRoot: new Node(), loginRoot: new Node(), resultRoot: new Node(), statusOverlay: new Node(), statusLabel: new Label(), resultLabel: new Label() }; const instance = new Node(); const group = new Node('group-2'); instance.children.push(group); for (const name of ['游客登录', '版本显示', '微信登录', '手机登录', 'qq登录', '快速登录', '隐藏快速登录-1', '隐藏快速登录-2', '登录中遮罩', '登录头像背景框', '登录中头像', '登录中昵称', '登录中动画', '登录中头像框']) { const node = new Node(name); group.children.push(node); node.components.set(Sprite, new Sprite()); node.components.set(UITransform, new UITransform()); node.components.set(Label, new Label()); } return { refs, instance, group, visitor: instance.getChildByPath('group-2/游客登录')! }; } test('required binding fails explicitly, including missing Sprite', () => { const { CocosLocalLoginView } = load(); const f = fixture(); const view = new CocosLocalLoginView(f.refs); assert.throws(() => view.bindLogin(new Node(), () => {}), /游客登录/); f.visitor.components.delete(Sprite); assert.throws(() => view.bindLogin(f.instance, () => {}), /Sprite/); }); test('PageModel drives result labels and blocks room presentation', () => { const { CocosLocalLoginView } = load(); const f = fixture(); const view = new CocosLocalLoginView(f.refs); view.render({ page: 'lobby', self: { playerid: 19, nickname: '测试玩家', bean: 72, avatar: 'unused' } }); view.activate('lobby'); assert.equal(f.refs.resultLabel.string, '登录成功\n玩家 ID:19\n昵称:测试玩家\n豆豆:72'); assert.equal(f.refs.resultRoot.active, true); assert.equal(f.refs.loginRoot.active, false); assert.throws(() => view.activate('room'), /room/); }); test('busy mask and readiness gate touch, terminal disposal removes ownership', () => { const { CocosLocalLoginView } = load(); const f = fixture(); const view = new CocosLocalLoginView(f.refs); let clicks = 0; view.bindLogin(f.instance, () => clicks++); view.setVersion(123); assert.equal((f.instance.getChildByPath('group-2/版本显示')!.getComponent(Label) as Label).string, '123'); assert.equal(f.instance.getChildByPath('group-2/微信登录')!.active, false); view.render({ page: 'login', canLogin: false }); view.activate('login'); f.visitor.emit('touch-end'); assert.equal(clicks, 0); view.render({ page: 'login', canLogin: true }); view.setBusy(true); f.visitor.emit('touch-end'); assert.equal(clicks, 0); assert.equal(f.instance.getChildByPath('group-2/登录中遮罩')!.active, true); view.setBusy(false); f.visitor.emit('touch-end'); assert.equal(clicks, 1); view.showFailure({ state: 7, msg: '服务器拒绝' }); view.showOverlay({ kind: 'none' }); assert.match(f.refs.statusLabel.string, /服务器拒绝/); f.visitor.emit('touch-end'); assert.equal(clicks, 1); view.dispose(); assert.equal(f.visitor.listenerCount('touch-end'), 0); }); test('frame request is one shot and real cancellation removes pending draw callback', () => { const { createCocosFrameScheduler } = load(); const scheduler = createCocosFrameScheduler(); let calls = 0; const cancel = scheduler.request(() => calls++); cancel(); director.emit('draw'); assert.equal(calls, 0); scheduler.request(() => calls++); director.emit('draw'); director.emit('draw'); assert.equal(calls, 1); assert.equal(director.listenerCount('draw'), 0); }); test('asset load task reports validated native loader progress from zero through completion', async () => { const { createAssetLoadTask } = load(); const progress: number[] = []; let onProgress!: (completed: number, total: number) => void; let onComplete!: (error: unknown, asset?: unknown) => void; const task = createAssetLoadTask((progressCallback: typeof onProgress, completeCallback: typeof onComplete) => { onProgress = progressCallback; onComplete = completeCallback; }, (value: number) => progress.push(value)); assert.deepEqual(progress, [0]); onProgress(1, 2); onProgress(2, 2); onComplete(null, { uuid: 'login' }); assert.deepEqual(progress, [0, 0.5, 1]); assert.deepEqual(await task.promise, { uuid: 'login' }); }); test('asset load task rejects invalid counts and loader failures without reporting guessed progress', async () => { const { createAssetLoadTask } = load(); for (const invalid of [[0, 0], [-1, 2], [3, 2], [0.5, 2], [1, Number.NaN]]) { const progress: number[] = []; let onProgress!: (completed: number, total: number) => void; const task = createAssetLoadTask((progressCallback: typeof onProgress) => { onProgress = progressCallback; }, (value: number) => progress.push(value)); onProgress(invalid[0], invalid[1]); await assert.rejects(task.promise, /asset load progress/i); assert.deepEqual(progress, [0]); } let complete!: (error: unknown, asset?: unknown) => void; const failed = createAssetLoadTask((_progress: unknown, callback: typeof complete) => { complete = callback; }, () => {}); complete(new Error('decoder failed')); await assert.rejects(failed.promise, /decoder failed/); }); test('asset load task cancellation settles pending work and ignores late loader callbacks', async () => { const { createAssetLoadTask } = load(); const progress: number[] = []; let onProgress!: (completed: number, total: number) => void; let onComplete!: (error: unknown, asset?: unknown) => void; const task = createAssetLoadTask((progressCallback: typeof onProgress, completeCallback: typeof onComplete) => { onProgress = progressCallback; onComplete = completeCallback; }, (value: number) => progress.push(value)); task.cancel(); await assert.rejects(task.promise, /cancelled/i); onProgress(1, 2); onComplete(null, { uuid: 'late' }); assert.deepEqual(progress, [0]); }); test('failure display preserves nested server and cleanup errors', () => { const { CocosLocalLoginView } = load(); const f = fixture(); const view = new CocosLocalLoginView(f.refs); view.showFailure({ error: { state: 8, msg: '版本拒绝' }, cleanup: new Error('socket close failed') }); assert.match(f.refs.statusLabel.string, /版本拒绝/); assert.match(f.refs.statusLabel.string, /socket close failed/); }); test('component rejects non-debug/native before creating host or writing browser storage', async () => { const path = new URL('../../YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts', import.meta.url); assert.ok(existsSync(path), 'LocalPlatformLogin component must exist'); for (const [debug, native] of [[false, false], [true, true]]) { let hosts = 0; const exports: any = {}; class Component {} const decorator = (..._args: unknown[]) => (target: unknown) => target; runInNewContext(ts.transpileModule(readFileSync(path, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, experimentalDecorators: true }, }).outputText, { exports, require: (name: string) => { if (name === 'cc/env') return { DEBUG: debug }; if (name === 'cc') return { Component, Node, Label, Prefab: class {}, sys: { isNative: native }, _decorator: { ccclass: decorator, property: () => () => {} } }; if (name.endsWith('local-login-host.ts')) return { createLocalLoginHost: () => { hosts++; } }; if (name.endsWith('local-login-view.ts')) return load(); if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} }; throw new Error(`Unexpected dependency ${name}`); }, Error }); const component = new exports.LocalPlatformLogin(); await assert.rejects(async () => component.start(), /DEBUG.*browser/); assert.equal(hosts, 0); } }); test('component stop cancels pending draw and timer work', async () => { const path = new URL('../../YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts', import.meta.url); const exports: any = {}; const timers = new Map void>(); let timerId = 0; runInNewContext(ts.transpileModule(readFileSync(path, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, experimentalDecorators: true }, }).outputText, { exports, require: (name: string) => { if (name === 'cc/env') return { DEBUG: true }; if (name === 'cc') return { Component: class {}, Node, Label, Prefab: class {}, ProgressBar, sys: { isNative: false }, _decorator: { ccclass: () => (target: unknown) => target, property: () => () => {} } }; if (name.endsWith('local-login-host.ts')) return {}; if (name.endsWith('local-login-view.ts')) return load(); if (name.endsWith('remote-config-fetcher.ts')) return {}; throw new Error(`Unexpected dependency ${name}`); }, Error, performance: { now: () => 123 }, window: { setTimeout: (callback: () => void) => { timers.set(++timerId, callback); return timerId; }, clearTimeout: (id: number) => timers.delete(id), } }); const component = new exports.LocalPlatformLogin(); const draw = component.nextDraw(); const timer = component.delay(900); const drawRejection = assert.rejects(draw, /cancelled/); const timerRejection = assert.rejects(timer, /cancelled/); component.stop(); await Promise.all([drawRejection, timerRejection]); assert.equal(timers.size, 0); assert.equal(director.listenerCount('draw'), 0); assert.equal(component.getDiagnostics().pendingCount, 0); }); test('minimum display ignores an early timer wake and cancellation clears its remaining wait', async () => { const path = new URL('../../YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts', import.meta.url); const exports: any = {}; const timers = new Map void>(); let timerId = 0; let clock = 100; let hostOptions: any; runInNewContext(ts.transpileModule(readFileSync(path, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, experimentalDecorators: true }, }).outputText, { exports, require: (name: string) => { if (name === 'cc/env') return { DEBUG: true }; if (name === 'cc') return { Component: class {}, Node, Label, Sprite, UITransform, ProgressBar, Prefab: class {}, sys: { isNative: false }, _decorator: { ccclass: () => (target: unknown) => target, property: () => () => {} } }; if (name.endsWith('local-login-host.ts')) return { createLocalLoginHost: (options: unknown) => { hostOptions = options; return { start: async () => {}, stop() {}, getSnapshot: () => ({}) }; } }; if (name.endsWith('local-login-view.ts')) return load(); if (name.endsWith('remote-config-fetcher.ts')) return { HttpConfigFetcher: class {} }; throw new Error(`Unexpected dependency ${name}`); }, Error, performance: { now: () => clock }, window: { location: { search: '?profile=local' }, localStorage: {}, setTimeout: (callback: () => void) => { timers.set(++timerId, callback); return timerId; }, clearTimeout: (id: number) => timers.delete(id), } }); async function started() { const component = new exports.LocalPlatformLogin(); const f = fixture(); Object.assign(component, f.refs, { stopButton: new Node(), loadingPrefab: {}, loginPrefab: {} }); const logo = new Node('Layer1_Logo'); const group = new Node('group-1'); const bar = new Node('Spirit254'); bar.components.set(ProgressBar, new ProgressBar()); group.children.push(bar, new Node('Spirit3103'), new Node('Spirit3104')); logo.children.push(group, new Node('group-32')); component.loadingRoot.children.push(logo); component.loadingRoot.getComponentsInChildren = () => []; await component.start(); director.emit('draw'); await Promise.resolve(); return component; } const drain = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; function wake(at: number) { clock = at; const [id, callback] = timers.entries().next().value!; timers.delete(id); callback(); } const component = await started(); let completed = false; const minimum = hostOptions.waitForMinimumDisplay().then(() => { completed = true; }); await drain(); wake(599.8); await drain(); assert.equal(completed, false, '499.8 ms elapsed must not open minimum display gate'); assert.equal(component.getDiagnostics().events.some((event: any) => event.kind === 'minimum-display-ready'), false); assert.equal(timers.size, 1); wake(600); await minimum; assert.equal(completed, true); assert.equal(timers.size, 0); component.stop(); clock = 1000; const cancelled = await started(); const cancelledMinimum = hostOptions.waitForMinimumDisplay(); const rejection = assert.rejects(cancelledMinimum, /cancelled/); await drain(); wake(1499.8); await drain(); assert.equal(timers.size, 1); cancelled.stop(); await rejection; assert.equal(timers.size, 0); assert.equal(cancelled.getDiagnostics().pendingCount, 0); });