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
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": {}
}
@@ -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 });
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2597060c-ad16-46f1-89ed-42bfaec74ad9",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "aea065db-0dfa-4da8-a2cb-f1fe0165d910",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2c6ce55f-b5a3-4503-bc65-ca12c5537c8f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2bbf94d8-6aa1-4b5f-a94c-c3f12205f46c",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -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); };
} };
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5af2497c-15f2-4493-be9b-a6391d25b878",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -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);
});