feat: add local platform login Cocos scene and view
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"ver": "1.1.50",
|
||||
"importer": "scene",
|
||||
"imported": true,
|
||||
"uuid": "67e69aac-9982-4e78-a44c-1d9c42f016ae",
|
||||
"files": [
|
||||
".json"
|
||||
],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "6072a7f3-9095-4e71-9dbd-c952d89a64a7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { _decorator, Component, Label, Node, Prefab, Sprite, UITransform, instantiate, isValid, sys } from 'cc';
|
||||
import { DEBUG } from 'cc/env';
|
||||
import { HttpConfigFetcher } from '../../framework/config/remote-config-fetcher.ts';
|
||||
import type { NativeHost } from '../../framework/config/sources/native-settings.ts';
|
||||
import type { RuntimeConfig } from '../../framework/config/runtime-config.ts';
|
||||
import type { PageModel } from '../../framework/presentation/ui-contracts.ts';
|
||||
import { createLocalLoginHost, type LocalLoginHost } from './local-login-host.ts';
|
||||
import type { LocalLoginEvidence } from './local-login-transport.ts';
|
||||
import { CocosLocalLoginView, createCocosFrameScheduler, requireNode } from './local-login-view.ts';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
const MINIMUM_LOADING_MS = 500;
|
||||
|
||||
/** Explicit DEBUG browser scene entry. All business state belongs to its host Runtime. */
|
||||
@ccclass('LocalPlatformLogin')
|
||||
export class LocalPlatformLogin extends Component {
|
||||
@property(Prefab) loadingPrefab: Prefab | null = null;
|
||||
@property(Prefab) loginPrefab: Prefab | null = null;
|
||||
@property(Node) loadingRoot: Node | null = null;
|
||||
@property(Node) loginRoot: Node | null = null;
|
||||
@property(Node) resultRoot: Node | null = null;
|
||||
@property(Node) statusOverlay: Node | null = null;
|
||||
@property(Label) statusLabel: Label | null = null;
|
||||
@property(Label) resultLabel: Label | null = null;
|
||||
@property(Node) stopButton: Node | null = null;
|
||||
|
||||
private host: LocalLoginHost | null = null;
|
||||
private view: CocosLocalLoginView | null = null;
|
||||
private spinner: Node | null = null;
|
||||
private loginInstance: Node | null = null;
|
||||
private stopped = false;
|
||||
private firstDrawAt: number | null = null;
|
||||
private version: number | null = null;
|
||||
private readonly pending = new Set<() => void>();
|
||||
private readonly evidence: (LocalLoginEvidence & { at: number })[] = [];
|
||||
private pageModel: PageModel | null = null;
|
||||
private readonly stopTouch = () => this.stop();
|
||||
|
||||
async start(): Promise<void> {
|
||||
// This guard precedes reading browser storage, preparing account or creating socket.
|
||||
if (!DEBUG || sys.isNative) throw new Error('LocalPlatformLogin requires DEBUG browser preview');
|
||||
try {
|
||||
const loadingRoot = this.required(this.loadingRoot, 'loadingRoot');
|
||||
const loginRoot = this.required(this.loginRoot, 'loginRoot');
|
||||
const stopButton = this.required(this.stopButton, 'stopButton');
|
||||
this.view = new CocosLocalLoginView({ loadingRoot, loginRoot,
|
||||
resultRoot: this.required(this.resultRoot, 'resultRoot'),
|
||||
statusOverlay: this.required(this.statusOverlay, 'statusOverlay'),
|
||||
statusLabel: this.required(this.statusLabel, 'statusLabel'),
|
||||
resultLabel: this.required(this.resultLabel, 'resultLabel') });
|
||||
this.required(this.loadingPrefab, 'loadingPrefab');
|
||||
this.required(this.loginPrefab, 'loginPrefab');
|
||||
this.spinner = requireNode(loadingRoot, 'Layer614_Loading/group-40/载入动画');
|
||||
this.validateSprites(loadingRoot);
|
||||
if (this.spinner.getComponent(UITransform) === null || this.spinner.getComponent(Sprite) === null) {
|
||||
throw new Error('Loading spinner requires UITransform and Sprite');
|
||||
}
|
||||
stopButton.on(Node.EventType.TOUCH_END, this.stopTouch);
|
||||
this.view.activate('loading');
|
||||
const firstDraw = this.nextDraw().then(() => {
|
||||
this.firstDrawAt = performance.now();
|
||||
this.record({ kind: 'loading-first-draw', data: null });
|
||||
});
|
||||
// A configuration failure can cancel this frame before Runtime consumes its gate.
|
||||
void firstDraw.catch(() => {});
|
||||
const view = this.view;
|
||||
const browser = window as unknown as NativeHost & { returnCitySN?: unknown };
|
||||
this.host = createLocalLoginHost({
|
||||
configOptions: { mode: 'debug', hostKind: 'h5', search: window.location.search,
|
||||
win: browser, fetcher: new HttpConfigFetcher() },
|
||||
storage: window.localStorage, random: Math.random, now: Date.now,
|
||||
sampleHost: () => ({ location: null,
|
||||
...('returnCitySN' in browser ? { returnCitySN: browser.returnCitySN } : {}) }),
|
||||
view: {
|
||||
render: model => { this.pageModel = model; this.record({ kind: 'page-model', data: model }); view.render(model); },
|
||||
activate: page => { view.activate(page); this.record({ kind: 'page-active', data: page }); },
|
||||
showOverlay: model => view.showOverlay(model),
|
||||
},
|
||||
scheduler: createCocosFrameScheduler(),
|
||||
loadResources: () => this.loadResources(),
|
||||
waitForMinimumDisplay: async () => {
|
||||
await firstDraw;
|
||||
const drawnAt = this.required(this.firstDrawAt, 'loading first draw');
|
||||
await this.delay(Math.max(0, MINIMUM_LOADING_MS - (performance.now() - drawnAt)));
|
||||
this.record({ kind: 'minimum-display-ready', data: performance.now() - drawnAt });
|
||||
},
|
||||
createSocket: url => new WebSocket(url), record: event => this.record(event),
|
||||
onBusy: busy => view.setBusy(busy),
|
||||
onFailure: error => this.fail(error),
|
||||
});
|
||||
await this.host.start();
|
||||
} catch (error) { this.fail(error); }
|
||||
}
|
||||
|
||||
private required<T>(value: T | null, name: string): T {
|
||||
if (value === null || value === undefined) throw new Error(`LocalPlatformLogin missing ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
private record(event: LocalLoginEvidence): void {
|
||||
if (event.kind === 'startup-config') this.version = (event.data as RuntimeConfig).identity.version;
|
||||
this.evidence.push(Object.freeze({ ...event, at: performance.now() }));
|
||||
}
|
||||
|
||||
private validateSprites(root: Node): void {
|
||||
for (const sprite of root.getComponentsInChildren(Sprite)) {
|
||||
if (!sprite.node.activeInHierarchy) continue;
|
||||
const frame = sprite.spriteFrame;
|
||||
if (frame === null || !isValid(frame) || frame.texture === null || !isValid(frame.texture)) {
|
||||
throw new Error(`Invalid SpriteFrame/texture dependency: ${sprite.node.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadResources(): Promise<void> {
|
||||
if (this.stopped) throw new Error('Local login resources cancelled');
|
||||
const prefab = this.required(this.loginPrefab, 'loginPrefab');
|
||||
const loading = this.required(this.loadingPrefab, 'loadingPrefab');
|
||||
if (!isValid(prefab) || !isValid(prefab.data) || !isValid(loading) || !isValid(loading.data)) {
|
||||
throw new Error('Local login serialized Prefab dependency is invalid');
|
||||
}
|
||||
const instance = instantiate(prefab);
|
||||
this.loginInstance = instance;
|
||||
instance.parent = this.required(this.loginRoot, 'loginRoot');
|
||||
const view = this.required(this.view, 'view');
|
||||
view.bindLogin(instance, () => {
|
||||
const host = this.required(this.host, 'host');
|
||||
void host.login().catch(error => this.fail(error));
|
||||
});
|
||||
view.setVersion(this.required(this.version, 'resolved identity.version'));
|
||||
// Validate active prefab content before the root is exposed as the login page.
|
||||
for (const sprite of instance.getComponentsInChildren(Sprite)) {
|
||||
if (!sprite.node.active) continue;
|
||||
const frame = sprite.spriteFrame;
|
||||
if (frame === null || !isValid(frame) || frame.texture === null || !isValid(frame.texture)) {
|
||||
throw new Error(`Invalid login SpriteFrame/texture dependency: ${sprite.node.name}`);
|
||||
}
|
||||
}
|
||||
await this.nextDraw();
|
||||
this.record({ kind: 'resources-ready', data: { login: prefab.uuid, loading: loading.uuid } });
|
||||
}
|
||||
|
||||
private nextDraw(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.stopped) { reject(new Error('Local login frame cancelled')); return; }
|
||||
const cancelFrame = createCocosFrameScheduler().request(() => {
|
||||
this.pending.delete(cancel); resolve();
|
||||
});
|
||||
const cancel = () => { cancelFrame(); this.pending.delete(cancel); reject(new Error('Local login frame cancelled')); };
|
||||
this.pending.add(cancel);
|
||||
});
|
||||
}
|
||||
|
||||
private delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.stopped) { reject(new Error('Local login timer cancelled')); return; }
|
||||
const timer = window.setTimeout(() => { this.pending.delete(cancel); resolve(); }, milliseconds);
|
||||
const cancel = () => { window.clearTimeout(timer); this.pending.delete(cancel); reject(new Error('Local login timer cancelled')); };
|
||||
this.pending.add(cancel);
|
||||
});
|
||||
}
|
||||
|
||||
update(deltaSeconds: number): void {
|
||||
if (!this.stopped && this.spinner !== null && this.spinner.activeInHierarchy) this.spinner.angle -= deltaSeconds * 180;
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
this.stopped = true;
|
||||
for (const cancel of [...this.pending]) cancel();
|
||||
this.stopButton?.off(Node.EventType.TOUCH_END, this.stopTouch);
|
||||
this.view?.dispose();
|
||||
this.host?.stop();
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
if (this.stopped) return;
|
||||
this.record({ kind: 'component-failure', data: error });
|
||||
try { this.release(); }
|
||||
catch (cleanup) {
|
||||
this.record({ kind: 'cleanup-failure', data: cleanup });
|
||||
this.view?.showFailure({ error, cleanup });
|
||||
return;
|
||||
}
|
||||
this.view?.showFailure(error);
|
||||
if (this.view === null && this.statusLabel !== null) this.statusLabel.string = `验证失败:${String(error)}`;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.stopped) return;
|
||||
this.view?.showStopped();
|
||||
try { this.release(); }
|
||||
catch (error) { this.view?.showFailure(error); }
|
||||
this.record({ kind: 'stopped', data: null });
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
this.stop();
|
||||
if (this.loginInstance !== null && isValid(this.loginInstance)) this.loginInstance.destroy();
|
||||
}
|
||||
|
||||
/** Local memory only: callers must redact raw account/device IDs before sharing. */
|
||||
getDiagnostics() {
|
||||
return Object.freeze({ events: Object.freeze([...this.evidence]), snapshot: this.host?.getSnapshot(),
|
||||
pageModel: this.pageModel, stopped: this.stopped, pendingCount: this.pending.size,
|
||||
firstDrawAt: this.firstDrawAt, spinnerAngle: this.spinner?.angle });
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2597060c-ad16-46f1-89ed-42bfaec74ad9",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "aea065db-0dfa-4da8-a2cb-f1fe0165d910",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2c6ce55f-b5a3-4503-bc65-ca12c5537c8f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2bbf94d8-6aa1-4b5f-a94c-c3f12205f46c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { Director, Label, Node, Sprite, UITransform, director } from 'cc';
|
||||
import type { FrameScheduler, OverlayModel, PageId, PageModel, PlatformViewPort } from '../../framework/presentation/ui-contracts.ts';
|
||||
|
||||
interface ViewReferences {
|
||||
loadingRoot: Node; loginRoot: Node; resultRoot: Node;
|
||||
statusOverlay: Node; statusLabel: Label; resultLabel: Label;
|
||||
}
|
||||
|
||||
export function requireNode(root: Node, path: string): Node {
|
||||
const node = root.getChildByPath(path);
|
||||
if (node === null) throw new Error(`Local login binding missing node: ${path}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
function describeFailure(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const details = 'errors' in error ? (error as Error & { errors: unknown[] }).errors : [];
|
||||
return [error.message, ...details.map(describeFailure)].join('\n');
|
||||
}
|
||||
if (typeof error === 'string') return error;
|
||||
return JSON.stringify(error, (_key, value: unknown) => value instanceof Error ? describeFailure(value) : value);
|
||||
}
|
||||
|
||||
/** Projection of Controller models; owns only Cocos bindings and input state. */
|
||||
export class CocosLocalLoginView implements PlatformViewPort {
|
||||
private visitor: Node | null = null;
|
||||
private version: Label | null = null;
|
||||
private mask: Node | null = null;
|
||||
private touch: (() => void) | null = null;
|
||||
private canLogin = false;
|
||||
private busy = false;
|
||||
private terminal = false;
|
||||
private page: PageId = 'loading';
|
||||
|
||||
constructor(private readonly refs: ViewReferences) {
|
||||
for (const [name, value] of Object.entries(refs)) {
|
||||
if (value === null || value === undefined) throw new Error(`Local login binding missing ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
bindLogin(instance: Node, onLogin: () => void): void {
|
||||
if (this.visitor !== null || this.terminal) throw new Error('Local login view cannot bind twice or after disposal');
|
||||
const visitor = requireNode(instance, 'group-2/游客登录');
|
||||
if (visitor.getComponent(Sprite) === null) throw new Error('游客登录 requires Sprite');
|
||||
if (visitor.getComponent(UITransform) === null) throw new Error('游客登录 requires UITransform');
|
||||
const version = requireNode(instance, 'group-2/版本显示').getComponent(Label);
|
||||
if (version === null) throw new Error('版本显示 requires Label');
|
||||
const mask = requireNode(instance, 'group-2/登录中遮罩');
|
||||
const hidden = ['微信登录', '手机登录', 'qq登录', '快速登录', '隐藏快速登录-1', '隐藏快速登录-2',
|
||||
'登录头像背景框', '登录中头像', '登录中昵称', '登录中动画', '登录中头像框']
|
||||
.map(name => requireNode(instance, `group-2/${name}`));
|
||||
hidden.forEach(node => { node.active = false; });
|
||||
this.visitor = visitor; this.version = version; this.mask = mask;
|
||||
this.touch = () => {
|
||||
if (!this.terminal && !this.busy && this.canLogin && this.page === 'login') onLogin();
|
||||
};
|
||||
visitor.on(Node.EventType.TOUCH_END, this.touch);
|
||||
mask.active = this.busy;
|
||||
}
|
||||
|
||||
setVersion(version: number): void {
|
||||
if (this.version === null) throw new Error('Login instance must be bound before version display');
|
||||
this.version.string = String(version);
|
||||
}
|
||||
|
||||
render(model: PageModel): void {
|
||||
if (this.terminal) return;
|
||||
switch (model.page) {
|
||||
case 'loading': this.refs.statusLabel.string = '正在加载资源和连接本地服务…'; break;
|
||||
case 'login': this.canLogin = model.canLogin; break;
|
||||
case 'lobby':
|
||||
this.refs.resultLabel.string = `登录成功\n玩家 ID:${model.self.playerid}\n昵称:${model.self.nickname}\n豆豆:${model.self.bean}`;
|
||||
break;
|
||||
case 'room': throw new Error('Local login view cannot present room');
|
||||
}
|
||||
}
|
||||
|
||||
activate(page: PageId): void {
|
||||
if (this.terminal) return;
|
||||
if (page === 'room') throw new Error('Local login view cannot activate room');
|
||||
this.page = page;
|
||||
this.refs.loadingRoot.active = page === 'loading';
|
||||
this.refs.loginRoot.active = page === 'login';
|
||||
this.refs.resultRoot.active = page === 'lobby';
|
||||
this.refs.statusOverlay.active = page === 'loading' || this.busy;
|
||||
}
|
||||
|
||||
setBusy(busy: boolean): void {
|
||||
if (this.terminal) return;
|
||||
this.busy = busy;
|
||||
if (this.mask !== null) this.mask.active = busy;
|
||||
this.refs.statusOverlay.active = busy || this.page === 'loading';
|
||||
if (busy) this.refs.statusLabel.string = '正在登录,请稍候…';
|
||||
}
|
||||
|
||||
showOverlay(model: OverlayModel): void {
|
||||
if (this.terminal) return;
|
||||
switch (model.kind) {
|
||||
case 'none': this.refs.statusOverlay.active = this.busy || this.page === 'loading'; break;
|
||||
case 'reconnect':
|
||||
this.refs.statusOverlay.active = true;
|
||||
this.refs.statusLabel.string = '连接中断,正在重连…'; break;
|
||||
case 'denial': this.showFailure(model.outcome.data); break;
|
||||
case 'kicked': this.showFailure(model.data); break;
|
||||
case 'fatal': this.showFailure(model.error); break;
|
||||
}
|
||||
}
|
||||
|
||||
showFailure(error: unknown): void {
|
||||
this.dispose();
|
||||
this.refs.statusOverlay.active = true;
|
||||
this.refs.statusLabel.string = `验证失败:${describeFailure(error)}`;
|
||||
}
|
||||
|
||||
showStopped(): void {
|
||||
if (this.terminal) return;
|
||||
this.dispose();
|
||||
this.refs.statusOverlay.active = true;
|
||||
this.refs.statusLabel.string = '已停止本次验证';
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.terminal = true; this.canLogin = false; this.busy = false;
|
||||
if (this.visitor !== null && this.touch !== null) this.visitor.off(Node.EventType.TOUCH_END, this.touch);
|
||||
this.touch = null;
|
||||
if (this.mask !== null) this.mask.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCocosFrameScheduler(): FrameScheduler {
|
||||
return { request(callback) {
|
||||
const onDraw = () => { director.off(Director.EVENT_AFTER_DRAW, onDraw); callback(); };
|
||||
director.on(Director.EVENT_AFTER_DRAW, onDraw);
|
||||
return () => { director.off(Director.EVENT_AFTER_DRAW, onDraw); };
|
||||
} };
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5af2497c-15f2-4493-be9b-a6391d25b878",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "96a339e3-f954-4e96-b07f-9fe124d457f0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user