feat: add local platform login Cocos scene and view

This commit is contained in:
2026-09-05 18:53:44 +08:00
parent fff4848654
commit 690fdef78a
12 changed files with 1955 additions and 0 deletions
@@ -0,0 +1,145 @@
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 Label { string = ''; }
class Node extends EventEmitter {
static EventType = { TOUCH_END: 'touch-end' };
active = true;
children: Node[] = [];
components = new Map<unknown, unknown>();
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('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 and disables spinner updates', async () => {
const path = new URL('../../YouleNexus/assets/scripts/local-platform-login/LocalPlatformLogin.ts', import.meta.url);
const exports: any = {}; const timers = new Map<number, () => 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 {}, 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.spinner = { angle: 50, activeInHierarchy: true };
component.update(0.5); assert.equal(component.spinner.angle, -40);
component.stop(); await Promise.all([drawRejection, timerRejection]);
component.update(0.5); assert.equal(component.spinner.angle, -40);
assert.equal(timers.size, 0); assert.equal(director.listenerCount('draw'), 0);
assert.equal(component.getDiagnostics().pendingCount, 0);
});