feat: migrate platform login scenes and native login flows
This commit is contained in:
@@ -2,7 +2,9 @@ import { remoteFixture } from '../../fixtures/remote-config.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||
import { STARTUP_CONFIG } from '../../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import type { LocalStartupContext } from '../../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
import { LOCAL_MACHINE_KEY, localAccountKey, type LocalStoragePort } from '../../../YouleNexus/assets/framework/adapters/local/local-storage.ts';
|
||||
import { createLocalLoginSources, type LocalHostSnapshot } from '../../../YouleNexus/assets/framework/adapters/local/login-sources.ts';
|
||||
|
||||
@@ -2,7 +2,9 @@ import { remoteFixture } from '../../fixtures/remote-config.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||
import { STARTUP_CONFIG } from '../../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import type { LocalStartupContext } from '../../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
import { LOCAL_MACHINE_KEY, localAccountKey, type LocalStoragePort } from '../../../YouleNexus/assets/framework/adapters/local/local-storage.ts';
|
||||
import { createLocalVisitorProvider } from '../../../YouleNexus/assets/framework/adapters/local/visitor-account.ts';
|
||||
@@ -121,7 +123,7 @@ test('scope isolates identity types, ignores version, and serializes the server
|
||||
assert.notEqual(localAccountKey(base), localAccountKey(context({ marketid: '7', version: 1 })));
|
||||
assert.equal(localAccountKey(base), localAccountKey(context({ marketid: 7, version: 999 })));
|
||||
assert.equal(localAccountKey(base),
|
||||
'youle.local-test.account.v1:["local",["ws://127.0.0.1:3088"],"veRa0qrBf0df2K1G4de2tgfmVxB2jxpv","J9r4022t80pxlb01zOK76DN1WD4QewFl","FtJf073aa0d6rI1xD8J1Y42fINTm0ziK",7]');
|
||||
'youle.local-test.account.v1:["local",["ws://127.0.0.1:3088"],"1B2h0ccl205c390Y28m1Ajdplkuu4wgy","J9r4022t80pxlb01zOK76DN1WD4QewFl","aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG",7]');
|
||||
});
|
||||
|
||||
test('reset is isolated, refuses active sessions, and refuses overlap with getAccount', async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveBootstrap } from '../../YouleNexus/assets/framework/config/bootstrap.ts';
|
||||
@@ -9,7 +11,7 @@ test('resolveBootstrap:显式 hostKind 透传严格 RuntimeConfig,并保留
|
||||
async fetch() { return { player_server_tcp: '5.5.5.5:5000' }; },
|
||||
};
|
||||
const result = await resolveBootstrap({
|
||||
hostKind: 'h5', win: {}, search: '', fetcher,
|
||||
startupConfig:STARTUP_CONFIG, hostKind: 'h5', win: {}, search: '', fetcher,
|
||||
});
|
||||
assert.equal(result.source, 'remote');
|
||||
assert.equal(result.profile.name, 'local');
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { ConfigFetchError, ConfigParseError } from '../../YouleNexus/assets/framework/config/remote-config.ts';
|
||||
|
||||
// 在引擎资源边界验证错误、JSON 解析和缓存释放,不以 HTTP 模拟项目资源。
|
||||
function fixture(debug: boolean, text: string, error?: Error) {
|
||||
const calls: string[] = [];
|
||||
const exports: any = {};
|
||||
class TextAsset {}
|
||||
const resources = {
|
||||
load(path: string, type: unknown, done: Function) {
|
||||
assert.equal(type, TextAsset); calls.push(`load:${path}`); done(error, { text });
|
||||
},
|
||||
release(path: string, type: unknown) { assert.equal(type, TextAsset); calls.push(`release:${path}`); },
|
||||
};
|
||||
const source = readFileSync(new URL('../../YouleNexus/assets/scripts/platform-login/local-config-fetcher.ts', import.meta.url), 'utf8');
|
||||
runInNewContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText, { exports, require(name: string) {
|
||||
if (name === 'cc') return { resources, TextAsset };
|
||||
if (name === 'cc/env') return { DEBUG: debug };
|
||||
if (name.endsWith('/remote-config.ts')) return { ConfigFetchError, ConfigParseError };
|
||||
throw Error(`Unexpected import: ${name}`);
|
||||
} });
|
||||
return { fetcher: new exports.CocosLocalConfigFetcher(), calls };
|
||||
}
|
||||
|
||||
test('project txt parses JSON and releases the asset after every load', async () => {
|
||||
const f = fixture(true, '{"showVisitor":1}');
|
||||
assert.equal((await f.fetcher.fetch('dev-config/test')).showVisitor, 1);
|
||||
await f.fetcher.fetch('dev-config/test');
|
||||
assert.deepEqual(f.calls, ['load:dev-config/test', 'release:dev-config/test', 'load:dev-config/test', 'release:dev-config/test']);
|
||||
});
|
||||
|
||||
test('release build never loads local txt; missing or malformed assets expose source errors', async () => {
|
||||
const release = fixture(false, '{}');
|
||||
await assert.rejects(release.fetcher.fetch('dev-config/test'), ConfigFetchError);
|
||||
assert.deepEqual(release.calls, []);
|
||||
const missing = fixture(true, '', Error('not found'));
|
||||
await assert.rejects(missing.fetcher.fetch('dev-config/test'), /test.txt.*not found/);
|
||||
const malformed = fixture(true, 'not json');
|
||||
await assert.rejects(malformed.fetcher.fetch('dev-config/test'), ConfigParseError);
|
||||
assert.deepEqual(malformed.calls, ['load:dev-config/test', 'release:dev-config/test']);
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
resolveLocalStartup,
|
||||
type LocalStartupContext,
|
||||
} from '../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||
import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config.ts';
|
||||
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||
@@ -22,7 +24,7 @@ function options(search = ''): ResolveRuntimeConfigOptions & {
|
||||
return { player_server_tcp: 'remote.example:3088' };
|
||||
},
|
||||
};
|
||||
return { hostKind: 'h5', win: {}, search, fetcher };
|
||||
return { startupConfig:STARTUP_CONFIG, hostKind: 'h5', win: {}, search, fetcher };
|
||||
}
|
||||
|
||||
test('local debug fetches remote config before choosing authoritative local servers', async () => {
|
||||
@@ -174,3 +176,12 @@ test('local consumers validate the injected source snapshot and its exact target
|
||||
assert.throws(()=>assertLocalStartupContext({...context,config:{...context.config,startupConfig:{...context.config.startupConfig,...patch}}} as any),/mode|useLocalServer|localServers|gameserver/);
|
||||
}
|
||||
});
|
||||
|
||||
test('local startup validates the selected project txt source', async () => {
|
||||
const localFetcher={async fetch(){return {showVisitor:1};}};
|
||||
const context=await resolveLocalStartup({startupConfig:{...STARTUP_CONFIG,configSource:'local'},hostKind:'h5',win:{},search:'',
|
||||
fetcher:{async fetch(){throw Error('remote forbidden');}},localFetcher});
|
||||
assert.equal(context.config.source,'local');
|
||||
assert.equal(context.config.gameserver,STARTUP_CONFIG.localConfigResource);
|
||||
assert.doesNotThrow(()=>assertLocalStartupContext(context));
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
STARTUP_CONFIG,
|
||||
STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG,
|
||||
validateStartupConfig,
|
||||
decodeGameConfig,
|
||||
resolveGameServer,
|
||||
} from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
|
||||
test('decodeGameConfig:按旧协议把 -/# 解码为远程 txt URL', () => {
|
||||
assert.equal(decodeGameConfig('host-path#8080-config'), 'http://host/path:8080/config.txt');
|
||||
@@ -23,7 +25,7 @@ test('resolveGameServer:空 gameconfig 使用配置源,非空值使用解码
|
||||
});
|
||||
|
||||
test('one frozen source defines the complete default startup configuration', () => {
|
||||
assert.deepEqual(STARTUP_CONFIG, { mode: 'debug', gameserver: 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt', useLocalServer: true, localServers: ['ws://127.0.0.1:3088'] });
|
||||
assert.deepEqual(STARTUP_CONFIG, { mode: 'debug', configSource: 'remote', localConfigResource: 'dev-config/update_jsonv2', gameserver: 'https://tsgames.daoqi88.cn/config_test/update_jsonv2.txt', useLocalServer: true, localServers: ['ws://127.0.0.1:3088'] });
|
||||
assert.ok(Object.isFrozen(STARTUP_CONFIG)); assert.ok(Object.isFrozen(STARTUP_CONFIG.localServers));
|
||||
});
|
||||
test('source validation rejects malformed configuration without defaults', () => {
|
||||
@@ -32,3 +34,11 @@ test('source validation rejects malformed configuration without defaults', () =>
|
||||
}
|
||||
for (const url of ['ws://127.4.3.2:3088','wss://localhost:3088','ws://[::1]:3088']) assert.deepEqual(validateStartupConfig({...STARTUP_CONFIG,localServers:[url]}).localServers,[url]);
|
||||
});
|
||||
|
||||
test('config source and local resource paths are explicit and local is debug only', () => {
|
||||
for (const patch of [{configSource:undefined},{configSource:'other'},{configSource:'local',mode:'release'},
|
||||
{localConfigResource:''},{localConfigResource:'../outside'},{localConfigResource:'dev-config/a.txt'},{localConfigResource:'/absolute'}]) {
|
||||
assert.throws(()=>validateStartupConfig({...STARTUP_CONFIG,...patch} as any), /startup config/);
|
||||
}
|
||||
assert.equal(validateStartupConfig({...STARTUP_CONFIG,configSource:'local'}).configSource,'local');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts';
|
||||
@@ -36,7 +38,7 @@ test('resolveRuntimeConfig:把 cacheBust 注入唯一的 HTTP 请求构造点'
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.input, 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt?123');
|
||||
assert.equal(calls[0]?.input, `${STARTUP_CONFIG.gameserver}?123`);
|
||||
assert.deepEqual(calls[0]?.init, { method: 'POST', body: '' });
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,19 @@ function legacy(raw: unknown, name: string, selected = identity) {
|
||||
GameData: { AgentId: selected.agentid, GameId: selected.gameid, ChannelId: selected.channelid, marketID: selected.marketid },
|
||||
});
|
||||
}
|
||||
|
||||
test('get_paravalue is the shared legacy-compatible reader, including an absent data wrapper', () => {
|
||||
assert.equal(typeof api.get_paravalue, 'function');
|
||||
const raw = {showVisitor:1,agentlist:[
|
||||
{agentid:11,showVisitor:0,gamelist:[{gameid:22,deviceLogin:true}]},
|
||||
{agentid:11,showVisitor:2},
|
||||
]};
|
||||
for(const name of ['showVisitor','deviceLogin','missing']) {
|
||||
assert.deepEqual(api.get_paravalue({data:raw},name,identity),legacy(raw,name));
|
||||
assert.deepEqual(view(raw).getValue(name),api.get_paravalue({data:raw},name,identity));
|
||||
}
|
||||
assert.equal(api.get_paravalue({},'showVisitor',identity),legacy(undefined,'showVisitor'));
|
||||
});
|
||||
test('memory view agrees with actual legacy function across all five scopes and preserves raw objects', () => {
|
||||
const scopes: Record<string, unknown>[] = [
|
||||
{ hall_config: { root: true }, rootOnly: 'root', falsy: 'inherited' },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||
@@ -30,9 +32,9 @@ test('h5:只读 query 身份,不访问原生宿主,并生成严格 remote
|
||||
assert.deepEqual(result.identity, { ...BUILD_IDENTITY, agentid: 'HA', channelid: 'HC' });
|
||||
assert.deepEqual(result.servers, ['ws://5.5.5.5:5000', 'ws://5.5.5.5:5000']);
|
||||
assert.equal(result.source, 'remote');
|
||||
assert.equal(result.gameserver, 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt');
|
||||
assert.equal(result.gameserver, STARTUP_CONFIG.gameserver);
|
||||
assert.equal(result.rawConfig, REMOTE);
|
||||
assert.deepEqual(fetcher.calls, ['https://tsgames.daoqi88.cn/config/update_jsonv2.txt']);
|
||||
assert.deepEqual(fetcher.calls, [STARTUP_CONFIG.gameserver]);
|
||||
assert.equal(Object.isFrozen(result), true);
|
||||
assert.equal(Object.isFrozen(result.identity), true);
|
||||
assert.equal(Object.isFrozen(result.servers), true);
|
||||
@@ -201,7 +203,7 @@ for (const mode of ['debug','release'] as const) for (const useLocalServer of [t
|
||||
source.localServers=['ws://localhost:9999']; assert.deepEqual(result.startupConfig.localServers,STARTUP_CONFIG.localServers);
|
||||
});
|
||||
test('default runtime starts without query configuration', async () => {
|
||||
const result=await resolveRuntimeConfig({hostKind:'h5',win:{},search:'',fetcher:returning({notice:'kept'})});
|
||||
const result=await resolveRuntimeConfig({startupConfig:STARTUP_CONFIG,hostKind:'h5',win:{},search:'',fetcher:returning({notice:'kept'})});
|
||||
assert.deepEqual(result.startupConfig,STARTUP_CONFIG); assert.deepEqual(result.servers,STARTUP_CONFIG.localServers);
|
||||
});
|
||||
|
||||
@@ -216,3 +218,38 @@ for (const mode of ['debug','release'] as const) for (const useLocalServer of [t
|
||||
const fetcher={async fetch(){throw new Error('remote unavailable');}};
|
||||
await assert.rejects(resolveRuntimeConfig({startupConfig:{...STARTUP_CONFIG,mode,useLocalServer},hostKind:'h5',win:{},search:'',fetcher}),/远程配置抓取失败/);
|
||||
});
|
||||
|
||||
// 本地来源必须在运行时边界隔离网络读取,并复用正式配置的层级取值规则。
|
||||
test('local txt selects one source, ignores gameconfig and preserves scoped values', async () => {
|
||||
const remote = returning(REMOTE);
|
||||
const local = returning({showVisitor: 1, agentlist: [{agentid: BUILD_IDENTITY.agentid, deviceLogin: true}]});
|
||||
const result = await resolveRuntimeConfig({startupConfig:{...STARTUP_CONFIG, configSource:'local'},
|
||||
hostKind:'h5',win:{},search:'?gameconfig=unwanted-host',fetcher:remote,localFetcher:local});
|
||||
assert.equal(result.source,'local');
|
||||
assert.deepEqual(remote.calls,[]);
|
||||
assert.deepEqual(local.calls,[STARTUP_CONFIG.localConfigResource]);
|
||||
assert.equal(result.remoteConfig.getValue('showVisitor'),1);
|
||||
assert.equal(result.remoteConfig.getValue('deviceLogin'),true);
|
||||
assert.deepEqual(result.servers,STARTUP_CONFIG.localServers);
|
||||
});
|
||||
|
||||
test('local txt errors never fall back to remote, release rejects before I/O', async () => {
|
||||
const remote=returning(REMOTE); let localCalls=0;
|
||||
const localFetcher={async fetch(){localCalls++;throw Error('missing txt');}};
|
||||
const options={startupConfig:{...STARTUP_CONFIG,configSource:'local' as const},hostKind:'h5' as const,win:{},search:'',fetcher:remote,localFetcher};
|
||||
await assert.rejects(resolveRuntimeConfig(options),/本地配置读取失败/);
|
||||
assert.equal(localCalls,1);assert.equal(remote.calls.length,0);
|
||||
await assert.rejects(resolveRuntimeConfig({...options,startupConfig:{...options.startupConfig,mode:'release'}}),/configSource/);
|
||||
assert.equal(localCalls,1);
|
||||
await assert.rejects(resolveRuntimeConfig({...options,localFetcher:undefined}),/localFetcher/);
|
||||
});
|
||||
|
||||
test('local txt and socket target are independent; remote mode never touches local loader', async () => {
|
||||
const remote=returning(REMOTE),local=returning(REMOTE);
|
||||
const options={hostKind:'h5' as const,win:{},search:'',fetcher:remote,localFetcher:local};
|
||||
const result=await resolveRuntimeConfig({...options,startupConfig:{...STARTUP_CONFIG,configSource:'local',useLocalServer:false}});
|
||||
assert.deepEqual(result.servers,['ws://5.5.5.5:5000','ws://5.5.5.5:5000']);
|
||||
assert.equal(remote.calls.length,0);assert.equal(local.calls.length,1);
|
||||
await resolveRuntimeConfig({...options,startupConfig:{...STARTUP_CONFIG,configSource:'remote'}});
|
||||
assert.equal(remote.calls.length,1);assert.equal(local.calls.length,1);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveLocalStartup } from '../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
@@ -13,7 +14,7 @@ class MapStorage implements LocalStoragePort {
|
||||
}
|
||||
|
||||
function resolve(search = '?profile=local') {
|
||||
return resolveLocalStartup({ hostKind: 'h5', win: {}, search,
|
||||
return resolveLocalStartup({ startupConfig:{...STARTUP_CONFIG,configSource:'remote'}, hostKind: 'h5', win: {}, search,
|
||||
fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } } });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { remoteFixture } from '../fixtures/remote-config.ts';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import type { PageModel, OverlayModel, PageId } from '../../YouleNexus/assets/framework/presentation/ui-contracts.ts';
|
||||
import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
async function flush() { for (let i = 0; i < 30; i++) await Promise.resolve(); }
|
||||
function fixture(name = 'player-login-success.json') {
|
||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||
}
|
||||
class Socket {
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
closes = 0;
|
||||
closeError?: Error;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
send(frame: string) { this.sent.push(frame); }
|
||||
close() { this.closes++; if (this.closeError) throw this.closeError; this.readyState = 3; this.onclose?.(); }
|
||||
open() { this.readyState = 1; this.onopen?.(); }
|
||||
push(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); }
|
||||
}
|
||||
async function setup(overrides: { config?: Partial<ResolveRuntimeConfigOptions>; render?: (model: PageModel) => void;
|
||||
cancelFrame?: () => void; showOverlay?: (model: OverlayModel) => void;
|
||||
record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void;
|
||||
onConfigResolved?: (config: RuntimeConfig) => void; onResourcesReady?: () => void;
|
||||
onSocketReady?: (ready: boolean) => void; onStopped?: () => void } = {}) {
|
||||
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-host.ts')
|
||||
.catch(error => assert.fail(`Local login host implementation must exist: ${String(error)}`));
|
||||
const resources = deferred(), minimum = deferred();
|
||||
const sockets: Socket[] = [], pages: PageModel[] = [], overlays: OverlayModel[] = [], activations: PageId[] = [];
|
||||
const failures: unknown[] = [], busy: boolean[] = [], evidence: { kind: string; data: unknown }[] = [];
|
||||
const values = new Map<string, string>(), frames = new Set<() => void>();
|
||||
let fetches = 0;
|
||||
const host = api.createLocalLoginHost({
|
||||
onConfigResolved: overrides.onConfigResolved, onResourcesReady: overrides.onResourcesReady,
|
||||
onSocketReady: overrides.onSocketReady, onStopped: overrides.onStopped,
|
||||
configOptions: { hostKind: 'h5', win: {}, search: '?profile=local&version=731',
|
||||
fetcher: { async fetch() { fetches++; return { player_server_tcp: 'remote.example:3088' }; } }, ...overrides.config },
|
||||
storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } },
|
||||
random: () => .5, now: () => 123456, sampleHost: () => ({ location: null }),
|
||||
view: { render: model => { overrides.render?.(model); pages.push(model); },
|
||||
activate: page => { activations.push(page); }, showOverlay: model => { overlays.push(model); overrides.showOverlay?.(model); } },
|
||||
scheduler: { request: callback => { frames.add(callback); return () => { frames.delete(callback); overrides.cancelFrame?.(); }; } },
|
||||
loadResources: () => resources.promise, waitForMinimumDisplay: () => minimum.promise,
|
||||
createSocket: () => { const socket = new Socket(); sockets.push(socket); return socket as unknown as WebSocket; },
|
||||
record: event => { evidence.push(event); overrides.record?.(event); },
|
||||
onBusy: value => { busy.push(value); overrides.onBusy?.(value); }, onFailure: error => { failures.push(error); },
|
||||
});
|
||||
const draw = () => { for (const frame of [...frames]) { frames.delete(frame); frame(); } };
|
||||
const ready = async () => { const starting = host.start(); resources.resolve(); minimum.resolve(); await flush(); sockets[0]!.open(); await starting; draw(); };
|
||||
return { host, resources, minimum, sockets, pages, overlays, activations, failures, busy, evidence, values, frames, draw, ready, fetches: () => fetches };
|
||||
}
|
||||
|
||||
test('host exposes resolved config and independent socket/resources readiness before display, and stops late events', async () => {
|
||||
const configs: RuntimeConfig[] = [], socket: boolean[] = []; let resources = 0, stopped = 0;
|
||||
const h = await setup({ onConfigResolved: config => configs.push(config), onResourcesReady: () => resources++,
|
||||
onSocketReady: ready => socket.push(ready), onStopped: () => stopped++ });
|
||||
const starting = h.host.start(); await flush();
|
||||
assert.equal(configs.length, 1); assert.equal(configs[0].identity.version, 731);
|
||||
assert.equal(configs[0].remoteConfig.getValue('player_server_tcp'), 'remote.example:3088');
|
||||
h.sockets[0].open(); await flush(); assert.equal(socket[socket.length - 1], true);
|
||||
assert.equal(h.host.getSnapshot().ready, false);
|
||||
h.resources.resolve(); await flush(); assert.equal(resources, 1);
|
||||
assert.equal(h.host.getSnapshot().ready, false);
|
||||
h.minimum.resolve(); await starting; h.host.stop(); assert.equal(stopped, 1);
|
||||
const events = socket.length; h.sockets[0].open(); await flush(); assert.equal(socket.length, events);
|
||||
});
|
||||
|
||||
test('host stop cancels presentation and ignores a resource completion still pending', async () => {
|
||||
let resources = 0, stopped = 0;
|
||||
const h = await setup({ onResourcesReady: () => resources++, onStopped: () => stopped++ });
|
||||
const starting = h.host.start(); const rejection = assert.rejects(starting, /stop|terminal/);
|
||||
await flush(); h.host.stop(); h.resources.resolve(); h.minimum.resolve();
|
||||
await rejection; assert.equal(resources, 0); assert.equal(stopped, 1);
|
||||
});
|
||||
|
||||
for (const order of [
|
||||
['resources', 'socket', 'minimum'], ['resources', 'minimum', 'socket'],
|
||||
['socket', 'resources', 'minimum'], ['socket', 'minimum', 'resources'],
|
||||
['minimum', 'resources', 'socket'], ['minimum', 'socket', 'resources'],
|
||||
] as const) test(`all startup gates remain independent in ${order.join(' -> ')} order`, async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop());
|
||||
const starting = h.host.start(); await flush();
|
||||
for (let index = 0; index < order.length; index++) {
|
||||
const gate = order[index];
|
||||
if (gate === 'socket') h.sockets[0].open(); else h[gate].resolve();
|
||||
await flush();
|
||||
assert.equal(h.host.getSnapshot().ready, index === order.length - 1);
|
||||
assert.equal(h.activations.includes('login'), index === order.length - 1);
|
||||
}
|
||||
await starting; h.draw(); assert.equal(h.activations.includes('login'), true);
|
||||
});
|
||||
|
||||
test('pending config cannot be bypassed by previously completed resource/display tasks', async t => {
|
||||
const config = deferred(); let resolved = 0;
|
||||
const h = await setup({ config: { fetcher: { async fetch() { await config.promise; return {}; } } },
|
||||
onConfigResolved: () => resolved++ });
|
||||
t.after(() => h.host.stop()); const starting = h.host.start();
|
||||
h.resources.resolve(); h.minimum.resolve(); await flush();
|
||||
assert.equal(resolved, 0); assert.equal(h.sockets.length, 0); assert.equal(h.host.getSnapshot().ready, false);
|
||||
config.resolve(); await flush(); assert.equal(resolved, 1); assert.equal(h.host.getSnapshot().ready, false);
|
||||
h.sockets[0].open(); await starting; h.draw(); assert.equal(h.host.getSnapshot().ready, true);
|
||||
});
|
||||
|
||||
for (const missing of ['resources', 'minimum', 'socket'] as const) test(`login waits for ${missing}, and start composes only once`, async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop());
|
||||
const starting = h.host.start(); const repeated = h.host.start();
|
||||
assert.strictEqual(starting, repeated);
|
||||
await flush();
|
||||
if (missing !== 'resources') h.resources.resolve();
|
||||
if (missing !== 'minimum') h.minimum.resolve();
|
||||
if (missing !== 'socket') h.sockets[0]!.open();
|
||||
await flush();
|
||||
assert.equal(h.host.getSnapshot().ready, false);
|
||||
await assert.rejects(h.host.login(), /ready/i);
|
||||
assert.equal(h.values.size, 0); assert.deepEqual(h.sockets[0]!.sent, []);
|
||||
h.resources.resolve(); h.minimum.resolve(); if (missing === 'socket') h.sockets[0]!.open();
|
||||
await starting;
|
||||
assert.equal(h.host.getSnapshot().ready, true); assert.equal(h.fetches(), 1);
|
||||
assert.equal(h.sockets.length, 1);
|
||||
assert.equal(h.evidence.filter(event => event.kind === 'startup-config').length, 1);
|
||||
await h.host.login();
|
||||
assert.equal(JSON.parse(h.sockets[0]!.sent[0]!).data.version, 731);
|
||||
});
|
||||
|
||||
test('duplicate clicks send once; response updates the sole Store and lobby projection and clears busy', async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop()); await h.ready();
|
||||
const first = h.host.login(), second = h.host.login(); assert.strictEqual(first, second);
|
||||
assert.equal(h.host.getSnapshot().busy, true);
|
||||
await first; await h.host.login(); assert.equal(h.sockets[0]!.sent.length, 1);
|
||||
assert.deepEqual(h.busy, [true]);
|
||||
h.sockets[0]!.push(fixture()); h.draw();
|
||||
assert.equal(h.host.getSnapshot().state?.app.phase, 'logged-in');
|
||||
assert.equal(h.host.getSnapshot().state?.room.kind, 'outside');
|
||||
assert.deepEqual(h.pages[h.pages.length - 1], { page: 'lobby', self: { playerid: 430511, nickname: '测试号', avatar: 'http://a', bean: 0 } });
|
||||
assert.deepEqual(h.busy, [true, false]);
|
||||
const before = h.pages.length; h.host.stop(); h.draw(); assert.equal(h.pages.length, before);
|
||||
});
|
||||
|
||||
for (const rpc of ['player_login', 'kick_server']) test(`${rpc} retains original server reason through terminal cleanup`, async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login();
|
||||
const data = { state: 17, msg: '原始服务器原因' };
|
||||
h.sockets[0]!.push({ route: 'agent', rpc, data });
|
||||
assert.equal(h.failures.length, 1); assert.deepEqual(h.failures[0], data);
|
||||
assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.host.getSnapshot().busy, false);
|
||||
assert.equal(h.sockets[0]!.closes, 1);
|
||||
await assert.rejects(h.host.login(), /stop|terminal/i);
|
||||
});
|
||||
|
||||
test('room recovery is refused before any player or room state enters Store', async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login();
|
||||
h.sockets[0]!.push(fixture('player-login-room.json'));
|
||||
assert.match(String(h.failures[0]), /room recovery/);
|
||||
assert.equal(h.host.getSnapshot().state?.players.selfPlayerId, null);
|
||||
assert.equal(h.host.getSnapshot().state?.room.kind, 'outside');
|
||||
assert.equal(h.pages.some(model => model.page === 'lobby'), false);
|
||||
});
|
||||
|
||||
test('corrupt account cache prevents sending and preserves the cache', async t => {
|
||||
const h = await setup(); t.after(() => h.host.stop()); await h.ready();
|
||||
// Discover the authority-owned key from a prior independent local login.
|
||||
const prior = await setup(); t.after(() => prior.host.stop()); await prior.ready(); await prior.host.login();
|
||||
for (const [key, value] of prior.values) h.values.set(key, value);
|
||||
const accountKey = [...h.values].find(([, value]) => value.includes('account'))![0];
|
||||
h.values.set(accountKey, '{broken');
|
||||
await assert.rejects(h.host.login(), /invalid JSON/);
|
||||
assert.equal(h.sockets[0]!.sent.length, 0); assert.equal(h.values.get(accountKey), '{broken');
|
||||
assert.equal(h.failures.length, 1);
|
||||
});
|
||||
|
||||
for (const phase of ['config', 'resources', 'prepare'] as const) test(`stop during ${phase} suppresses late connection, send and render`, async () => {
|
||||
const h = await setup();
|
||||
let operation: Promise<void>;
|
||||
if (phase === 'prepare') { await h.ready(); operation = h.host.login(); }
|
||||
else { operation = h.host.start(); if (phase === 'resources') await flush(); }
|
||||
const settled = operation.catch(() => {});
|
||||
// Let config resolution / account preparation begin before terminal stop.
|
||||
if (phase !== 'resources') await Promise.resolve();
|
||||
h.host.stop(); const pageCount = h.pages.length;
|
||||
h.resources.resolve(); h.minimum.resolve(); await settled; await flush(); h.draw();
|
||||
assert.equal(h.pages.length, pageCount); assert.equal(h.frames.size, 0);
|
||||
assert.equal(h.sockets.flatMap(socket => socket.sent).length, 0);
|
||||
if (phase === 'config') assert.equal(h.sockets.length, 0);
|
||||
assert.deepEqual(h.failures, []);
|
||||
});
|
||||
|
||||
for (const config of [{ startupConfig: {...STARTUP_CONFIG,mode:'release' as const} }, { startupConfig: {...STARTUP_CONFIG,useLocalServer:false} }, { hostKind: 'native-settings' as const }]) test('invalid local input cannot create an account or connection', async () => {
|
||||
const h = await setup({ config });
|
||||
await assert.rejects(h.host.start(), /local startup/);
|
||||
assert.equal(h.values.size, 0); assert.equal(h.sockets.length, 0); assert.equal(h.failures.length, 1);
|
||||
});
|
||||
|
||||
test('resource failure is reported with original object after shutdown', async () => {
|
||||
const h = await setup(); const error = new Error('resource dependency missing');
|
||||
const starting = h.host.start(); await flush(); h.resources.reject(error); h.minimum.resolve();
|
||||
await assert.rejects(starting);
|
||||
assert.strictEqual(h.failures[0], error); assert.equal(h.sockets[0]!.closes, 1);
|
||||
});
|
||||
|
||||
test('observer and socket cleanup failures both survive terminal reporting', async t => {
|
||||
const observer = new Error('render observer failed'), cleanup = new Error('socket close failed');
|
||||
const h = await setup({ render: model => { if (model.page === 'lobby') throw observer; } });
|
||||
t.after(() => h.host.stop()); await h.ready(); await h.host.login(); h.sockets[0]!.closeError = cleanup;
|
||||
h.sockets[0]!.push(fixture());
|
||||
const contains = (value: unknown, target: unknown): boolean => value === target ||
|
||||
(!!value && typeof value === 'object' && 'errors' in value && (value.errors as unknown[]).some(error => contains(error, target)));
|
||||
assert.equal(contains(h.failures[0], observer), true); assert.equal(contains(h.failures[0], cleanup), true);
|
||||
});
|
||||
|
||||
test('resource error remains primary when closing its socket also fails', async () => {
|
||||
const h = await setup(); const primary = new Error('resource failed'), cleanup = new Error('close failed');
|
||||
const starting = h.host.start(); await flush(); h.sockets[0]!.closeError = cleanup;
|
||||
h.resources.reject(primary); h.minimum.resolve(); await assert.rejects(starting);
|
||||
const errors = (h.failures[0] as { errors: unknown[] }).errors;
|
||||
assert.strictEqual(errors[0], primary); assert.ok(errors.includes(cleanup));
|
||||
});
|
||||
|
||||
test('kick data remains primary when closing its socket also fails', async () => {
|
||||
const h = await setup(); await h.ready(); await h.host.login();
|
||||
const cleanup = new Error('kick close failed'); h.sockets[0]!.closeError = cleanup;
|
||||
const data = { msg: 'original kick reason' };
|
||||
h.sockets[0]!.push({ route: 'agent', rpc: 'kick_server', data });
|
||||
const errors = (h.failures[0] as { errors: unknown[] }).errors;
|
||||
assert.deepEqual(errors[0], data); assert.ok(errors.includes(cleanup));
|
||||
});
|
||||
|
||||
test('pending frame cancellation retains its primary error when socket cleanup also fails', async () => {
|
||||
const primary = new Error('pending frame cancel failed'), cleanup = new Error('socket close failed');
|
||||
let armed = false;
|
||||
const h = await setup({ cancelFrame: () => { if (armed) throw primary; } });
|
||||
await h.ready(); await h.host.login();
|
||||
armed = true; h.sockets[0]!.closeError = cleanup;
|
||||
// The real login response schedules a Store frame, then showLobby flush cancels it.
|
||||
h.sockets[0]!.push(fixture());
|
||||
assert.equal(h.failures.length, 1);
|
||||
const fault = h.failures[0] as { errors?: unknown[] };
|
||||
assert.ok(fault.errors, 'primary and cleanup failures must both be retained');
|
||||
assert.strictEqual(fault.errors[0], primary);
|
||||
assert.ok(fault.errors.includes(cleanup));
|
||||
assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.frames.size, 0);
|
||||
assert.equal(h.sockets[0]!.closes, 1);
|
||||
});
|
||||
|
||||
test('fatal overlay observer failure retains the original Runtime error object', async () => {
|
||||
const observer = new Error('fatal overlay observer failed');
|
||||
let primary: Error | undefined;
|
||||
const h = await setup({ showOverlay: model => {
|
||||
if (model.kind === 'fatal') { primary = model.error; throw observer; }
|
||||
} });
|
||||
await h.ready();
|
||||
h.sockets[0]!.push({ route: 'agent', rpc: 'unsupported_test_rpc', data: {} });
|
||||
assert.ok(primary); assert.match(primary.message, /Unsupported platform rpc/);
|
||||
assert.equal(h.failures.length, 1);
|
||||
const fault = h.failures[0] as { errors?: unknown[] };
|
||||
assert.ok(fault.errors, 'Runtime error and overlay observer error must both be retained');
|
||||
assert.strictEqual(fault.errors[0], primary); assert.strictEqual(fault.errors[1], observer);
|
||||
assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.sockets[0]!.closes, 1);
|
||||
});
|
||||
|
||||
test('remote fetch and parse failures leave local sockets and account storage untouched', async () => {
|
||||
for (const fetch of [async () => { throw new Error('network down'); }, async () => ({ agentlist: {} }), async () => null]) {
|
||||
const h = await setup({ config: { fetcher: { fetch } } });
|
||||
const result = h.host.start().catch(error => error);
|
||||
await flush();
|
||||
assert.equal(h.sockets.length, 0);
|
||||
assert.equal(h.values.size, 0);
|
||||
assert.ok(await result instanceof Error);
|
||||
assert.equal(h.failures.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('stop while remote fetch is pending prevents late socket creation', async () => {
|
||||
const pending = deferred();
|
||||
let fetched = false;
|
||||
const h = await setup({ config: { fetcher: { async fetch() {
|
||||
fetched = true; await pending.promise; return { player_server_tcp: 'remote.example:3088' };
|
||||
} } } });
|
||||
const result = h.host.start().catch(error => error);
|
||||
await flush(); assert.equal(fetched, true); assert.equal(h.sockets.length, 0);
|
||||
h.host.stop(); pending.resolve(); await result;
|
||||
assert.equal(h.sockets.length, 0); assert.equal(h.values.size, 0);
|
||||
});
|
||||
|
||||
test('startup diagnostics exclude raw remote fields', async t => {
|
||||
const h = await setup({ config: { fetcher: { async fetch() {
|
||||
return { player_server_tcp: 'remote.example:3088', privateConfig: 'DO_NOT_RECORD_REMOTE_SECRET' };
|
||||
} } } });
|
||||
t.after(() => h.host.stop()); await h.ready();
|
||||
assert.equal(JSON.stringify(h.evidence).includes('DO_NOT_RECORD_REMOTE_SECRET'), false);
|
||||
});
|
||||
|
||||
test('default no-query host reaches login ready and ignores conflicting selectors', async t => {
|
||||
for (const search of ['', '?mode=release&mode=other&profile=prod&profile=unknown']) {
|
||||
const h=await setup({config:{search}}); t.after(()=>h.host.stop()); await h.ready();
|
||||
assert.equal(h.fetches(),1); assert.equal(h.host.getSnapshot().ready,true); assert.ok(h.activations.includes('login')); assert.equal(h.failures.length,0);
|
||||
}
|
||||
});
|
||||
@@ -1,323 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { resolveLocalStartup } from '../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
import { WireClient } from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
||||
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts';
|
||||
|
||||
class FakeSocket {
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
closes = 0;
|
||||
sendError?: Error;
|
||||
closeError?: Error;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
send(frame: string): void {
|
||||
if (this.sendError) throw this.sendError;
|
||||
this.sent.push(frame);
|
||||
}
|
||||
close(): void {
|
||||
this.closes++;
|
||||
if (this.closeError) throw this.closeError;
|
||||
this.readyState = 3;
|
||||
this.onclose?.({} as CloseEvent);
|
||||
}
|
||||
open(): void { this.readyState = 1; this.onopen?.({} as Event); }
|
||||
message(data: unknown): void { this.onmessage?.({ data } as MessageEvent); }
|
||||
}
|
||||
|
||||
async function setup(overrides: {
|
||||
createSocket?: (url: string) => WebSocket;
|
||||
record?: (event: { kind: string; data: unknown }) => void;
|
||||
onFault?: (error: unknown) => void;
|
||||
} = {}) {
|
||||
const module = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-transport.ts')
|
||||
.catch((error: unknown) => assert.fail(`LocalLoginTransport implementation must exist: ${String(error)}`));
|
||||
const context = await resolveLocalStartup({
|
||||
hostKind: 'h5', win: {}, search: '?profile=local',
|
||||
fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } },
|
||||
});
|
||||
const sockets: FakeSocket[] = [];
|
||||
const urls: string[] = [];
|
||||
const faults: unknown[] = [];
|
||||
const evidence: { kind: string; data: unknown }[] = [];
|
||||
const transport = new module.LocalLoginTransport({
|
||||
context,
|
||||
createSocket: overrides.createSocket ?? ((url) => {
|
||||
urls.push(url);
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as WebSocket;
|
||||
}),
|
||||
record: overrides.record ?? ((event) => evidence.push(event)),
|
||||
onFault: (error) => { faults.push(error); overrides.onFault?.(error); },
|
||||
});
|
||||
return { transport, context, sockets, urls, faults, evidence, url: context.config.servers[0]! };
|
||||
}
|
||||
|
||||
test('requires OPEN and records only frames successfully sent', async () => {
|
||||
const h = await setup();
|
||||
assert.throws(() => h.transport.send('{}'), /open/i);
|
||||
h.transport.connect(h.url);
|
||||
assert.throws(() => h.transport.send('{}'), /open/i);
|
||||
const socket = h.sockets[0]!;
|
||||
socket.open();
|
||||
h.transport.send('frame');
|
||||
assert.deepEqual(socket.sent, ['frame']);
|
||||
assert.deepEqual(h.evidence, [{ kind: 'transport-send', data: 'frame' }]);
|
||||
const error = new Error('send failed');
|
||||
socket.sendError = error;
|
||||
assert.throws(() => h.transport.send('unsent'), (caught) => caught === error);
|
||||
assert.equal(h.evidence.length, 1);
|
||||
assert.equal(h.faults[h.faults.length - 1], error);
|
||||
});
|
||||
|
||||
test('validates every connection target before constructing and reports the thrown error', async () => {
|
||||
const h = await setup();
|
||||
h.transport.connect(h.url);
|
||||
assert.throws(() => h.transport.connect('wss://remote.example/'), (error) => {
|
||||
assert.equal(h.faults[h.faults.length - 1], error);
|
||||
return /loopback/i.test(String(error));
|
||||
});
|
||||
assert.deepEqual(h.urls, [h.url]);
|
||||
});
|
||||
|
||||
test('socket constructor failure is reported before rethrowing the same object and cause', async () => {
|
||||
const cause = new Error('root');
|
||||
const error = Object.assign(new Error('constructor failed'), { cause });
|
||||
const h = await setup({ createSocket: () => { throw error; } });
|
||||
assert.throws(() => h.transport.connect(h.url), (caught) => {
|
||||
assert.equal(h.faults[0], error);
|
||||
return caught === error && error.cause === cause;
|
||||
});
|
||||
});
|
||||
|
||||
test('records raw handshake before message delivery', async () => {
|
||||
const order: unknown[] = [];
|
||||
const h = await setup({ record: (event) => order.push(event) });
|
||||
h.transport.onMessage((frame) => order.push(frame));
|
||||
h.transport.connect(h.url);
|
||||
h.sockets[0]!.open();
|
||||
h.sockets[0]!.message('@toconcon raw handshake');
|
||||
assert.deepEqual(order, [
|
||||
{ kind: 'transport-receive', data: '@toconcon raw handshake' }, '@toconcon raw handshake',
|
||||
]);
|
||||
});
|
||||
|
||||
test('non-text frames stop, report TypeError and never reach consumers or reconnect callback', async () => {
|
||||
const h = await setup();
|
||||
let delivered = 0;
|
||||
let closed = 0;
|
||||
h.transport.onMessage(() => delivered++);
|
||||
h.transport.onClose(() => closed++);
|
||||
h.transport.connect(h.url);
|
||||
const socket = h.sockets[0]!;
|
||||
socket.open();
|
||||
socket.message(new ArrayBuffer(2));
|
||||
socket.message('late frame');
|
||||
assert.equal(delivered, 0);
|
||||
assert.equal(closed, 0);
|
||||
assert.equal(socket.closes, 1);
|
||||
assert.equal(h.faults.length, 1);
|
||||
assert.ok(h.faults[0] instanceof TypeError);
|
||||
assert.deepEqual(h.evidence, []);
|
||||
assert.throws(() => h.transport.send('late'), /open/i);
|
||||
});
|
||||
|
||||
for (const direction of ['receive', 'send']) {
|
||||
test(`${direction} evidence failure preserves original error and stops without reconnect`, async () => {
|
||||
const error = new Error('evidence storage failed');
|
||||
const h = await setup({ record: () => { throw error; } });
|
||||
let delivered = 0;
|
||||
let closed = 0;
|
||||
h.transport.onMessage(() => delivered++);
|
||||
h.transport.onClose(() => closed++);
|
||||
h.transport.connect(h.url);
|
||||
const socket = h.sockets[0]!;
|
||||
socket.open();
|
||||
const run = () => direction === 'receive' ? socket.message('frame') : h.transport.send('frame');
|
||||
assert.throws(run, (caught) => caught === error);
|
||||
assert.equal(h.faults[0], error);
|
||||
assert.equal(socket.closes, 1);
|
||||
assert.equal(delivered, 0);
|
||||
assert.equal(closed, 0);
|
||||
assert.throws(() => h.transport.send('late'), /open/i);
|
||||
});
|
||||
}
|
||||
|
||||
test('error followed by close notifies once and retains original error event', async () => {
|
||||
const h = await setup();
|
||||
let closed = 0;
|
||||
h.transport.onClose(() => closed++);
|
||||
h.transport.connect(h.url);
|
||||
const socket = h.sockets[0]!;
|
||||
const error = {} as Event;
|
||||
socket.onerror?.(error);
|
||||
socket.onclose?.({} as CloseEvent);
|
||||
assert.equal(closed, 1);
|
||||
assert.deepEqual(h.faults, []);
|
||||
assert.deepEqual(h.evidence, [{ kind: 'transport-error', data: error }]);
|
||||
});
|
||||
|
||||
test('explicit close and superseded sockets invalidate every old callback', async () => {
|
||||
const h = await setup();
|
||||
const calls: string[] = [];
|
||||
h.transport.onOpen(() => calls.push('open'));
|
||||
h.transport.onMessage(() => calls.push('message'));
|
||||
h.transport.onClose(() => calls.push('close'));
|
||||
h.transport.connect(h.url);
|
||||
const old = h.sockets[0]!;
|
||||
h.transport.connect(h.url);
|
||||
old.open(); old.message('old'); old.onerror?.({} as Event); old.onclose?.({} as CloseEvent);
|
||||
assert.equal(old.closes, 1);
|
||||
const current = h.sockets[1]!;
|
||||
current.open();
|
||||
h.transport.close();
|
||||
current.open(); current.message('late'); current.onerror?.({} as Event);
|
||||
assert.deepEqual(calls, ['open']);
|
||||
assert.deepEqual(h.faults, []);
|
||||
assert.deepEqual(h.evidence, []);
|
||||
});
|
||||
|
||||
test('close failure is reported and thrown after invalidation', async () => {
|
||||
const h = await setup();
|
||||
h.transport.connect(h.url);
|
||||
const socket = h.sockets[0]!;
|
||||
const error = new Error('close failed');
|
||||
socket.closeError = error;
|
||||
assert.throws(() => h.transport.close(), (caught) => caught === error);
|
||||
assert.equal(h.faults[0], error);
|
||||
assert.throws(() => h.transport.send('late'), /open/i);
|
||||
});
|
||||
|
||||
test('explicit stop from error evidence observer suppresses the pending error close callback', async () => {
|
||||
const h = await setup({ record: () => h.transport.close() });
|
||||
let closed = 0;
|
||||
h.transport.onClose(() => closed++);
|
||||
h.transport.connect(h.url);
|
||||
h.sockets[0]!.onerror?.({} as Event);
|
||||
assert.equal(closed, 0);
|
||||
});
|
||||
|
||||
test('a replacement connection from error evidence observer cannot receive the old close callback', async () => {
|
||||
const h = await setup({ record: () => h.transport.connect(h.url) });
|
||||
let closed = 0;
|
||||
h.transport.onClose(() => closed++);
|
||||
h.transport.connect(h.url);
|
||||
h.sockets[0]!.onerror?.({} as Event);
|
||||
assert.equal(h.sockets.length, 2);
|
||||
assert.equal(closed, 0);
|
||||
});
|
||||
|
||||
test('natural close lets real WireClient reconnect while explicit stop cancels it', async () => {
|
||||
const h = await setup();
|
||||
const timers = new Map<unknown, () => void>();
|
||||
const clock: Clock = {
|
||||
setTimeout: (fn) => { const key = {}; timers.set(key, fn); return key; },
|
||||
clearTimeout: (key) => { timers.delete(key); },
|
||||
};
|
||||
const wire = new WireClient({ servers: h.context.config.servers, transportFactory: () => h.transport, clock });
|
||||
wire.start();
|
||||
h.sockets[0]!.open();
|
||||
h.sockets[0]!.close();
|
||||
assert.equal(timers.size, 1);
|
||||
const retry = [...timers.values()][0]!;
|
||||
timers.clear(); retry();
|
||||
assert.equal(h.sockets.length, 2);
|
||||
wire.stop();
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('natural error reconnects real WireClient even when terminal onFault stops the host', async () => {
|
||||
const h = await setup({ onFault: () => wire.stop() });
|
||||
const timers = new Map<unknown, () => void>();
|
||||
const clock: Clock = {
|
||||
setTimeout: (fn) => { const key = {}; timers.set(key, fn); return key; },
|
||||
clearTimeout: (key) => { timers.delete(key); },
|
||||
};
|
||||
const wire = new WireClient({ servers: h.context.config.servers, transportFactory: () => h.transport, clock });
|
||||
wire.start();
|
||||
h.sockets[0]!.open();
|
||||
const error = {} as Event;
|
||||
h.sockets[0]!.onerror?.(error);
|
||||
h.sockets[0]!.onclose?.({} as CloseEvent);
|
||||
assert.deepEqual(h.faults, []);
|
||||
assert.deepEqual(h.evidence, [{ kind: 'transport-error', data: error }]);
|
||||
assert.equal(timers.size, 1);
|
||||
const retry = [...timers.values()][0]!;
|
||||
timers.clear(); retry();
|
||||
assert.equal(h.sockets.length, 2);
|
||||
wire.stop();
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
for (const failure of ['record', 'close']) {
|
||||
test(`socket error ${failure} failure reports terminal original and suppresses reconnect`, async () => {
|
||||
const error = new Error(`${failure} failed`);
|
||||
const h = await setup(failure === 'record' ? { record: () => { throw error; } } : {});
|
||||
let closed = 0;
|
||||
h.transport.onClose(() => closed++);
|
||||
h.transport.connect(h.url);
|
||||
const socket = h.sockets[0]!;
|
||||
if (failure === 'close') socket.closeError = error;
|
||||
const event = {} as Event;
|
||||
assert.throws(() => socket.onerror?.(event), (caught) => caught === error);
|
||||
assert.deepEqual(h.faults, [error]);
|
||||
assert.equal(closed, 0);
|
||||
socket.onclose?.({} as CloseEvent);
|
||||
assert.equal(closed, 0);
|
||||
assert.throws(() => h.transport.send('late'), /open/i);
|
||||
});
|
||||
}
|
||||
|
||||
test('non-text and cleanup failures reach a single terminal observer together', async () => {
|
||||
const observed: unknown[] = [];
|
||||
const h = await setup({ onFault: (error) => { if (observed.length === 0) observed.push(error); } });
|
||||
h.transport.connect(h.url);
|
||||
const socket = h.sockets[0]!;
|
||||
const cleanupError = new Error('close failed');
|
||||
socket.closeError = cleanupError;
|
||||
socket.message(new ArrayBuffer(1));
|
||||
assert.equal(h.faults.length, 1);
|
||||
assert.equal(observed[0], h.faults[0]);
|
||||
const fault = observed[0] as Error & { errors: unknown[] };
|
||||
assert.ok(fault instanceof Error);
|
||||
assert.equal(fault.errors.length, 2);
|
||||
assert.ok(fault.errors[0] instanceof TypeError);
|
||||
assert.equal(fault.errors[1], cleanupError);
|
||||
assert.throws(() => h.transport.send('late'), /open/i);
|
||||
});
|
||||
|
||||
for (const operation of ['receive-record', 'error-record', 'send-record', 'send']) {
|
||||
test(`${operation} and close failures are reported once and thrown as the same combined fault`, async () => {
|
||||
const primary = new Error('primary failed');
|
||||
const cleanup = new Error('close failed');
|
||||
const observed: unknown[] = [];
|
||||
const h = await setup({
|
||||
...(operation === 'send' ? {} : { record: () => { throw primary; } }),
|
||||
onFault: (error) => { if (observed.length === 0) observed.push(error); },
|
||||
});
|
||||
h.transport.connect(h.url);
|
||||
const socket = h.sockets[0]!;
|
||||
socket.open();
|
||||
socket.closeError = cleanup;
|
||||
if (operation === 'send') socket.sendError = primary;
|
||||
let thrown: unknown;
|
||||
try {
|
||||
if (operation === 'receive-record') socket.message('frame');
|
||||
else if (operation === 'error-record') socket.onerror?.({} as Event);
|
||||
else h.transport.send('frame');
|
||||
} catch (error) { thrown = error; }
|
||||
assert.equal(h.faults.length, 1);
|
||||
assert.equal(thrown, observed[0]);
|
||||
assert.equal(thrown, h.faults[0]);
|
||||
assert.ok(thrown instanceof Error);
|
||||
const errors = (thrown as Error & { errors: unknown[] }).errors;
|
||||
assert.equal(errors.length, 2);
|
||||
assert.equal(errors[0], primary);
|
||||
assert.equal(errors[1], cleanup);
|
||||
assert.throws(() => h.transport.send('late'), /open/i);
|
||||
});
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
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 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<unknown, unknown>();
|
||||
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<number, { at: number; callback: () => 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/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, 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);
|
||||
});
|
||||
|
||||
test('diagnostic stop button is placed below Logo across different parent coordinates without changing its right-side x', () => {
|
||||
const { positionDiagnosticStopButtonBelow } = load();
|
||||
assert.equal(typeof positionDiagnosticStopButtonBelow, 'function');
|
||||
const logo = new Node('Spirit3103'), logoParent = new Node('logo-parent');
|
||||
const stop = new Node('StopButton'), canvas = new Node('Canvas');
|
||||
logo.parent = logoParent; stop.parent = canvas; stop.position = new Vec3(665, 310, 7);
|
||||
const logoTransform = new UITransform();
|
||||
Object.assign(logoTransform, { width: 208, height: 59, anchorX: 0.5, anchorY: 0.5 });
|
||||
logoTransform.convertToWorldSpaceAR = value => new Vec3(value.x + 671.37, value.y + 311.5, value.z);
|
||||
const stopTransform = new UITransform();
|
||||
Object.assign(stopTransform, { width: 220, height: 64, anchorX: 0.5, anchorY: 0.5 });
|
||||
const canvasTransform = new UITransform();
|
||||
canvasTransform.convertToNodeSpaceAR = value => new Vec3(value.x - 100, value.y - 40, value.z);
|
||||
logo.components.set(UITransform, logoTransform); stop.components.set(UITransform, stopTransform);
|
||||
canvas.components.set(UITransform, canvasTransform);
|
||||
const widget = new Widget(); stop.components.set(Widget, widget);
|
||||
|
||||
positionDiagnosticStopButtonBelow(stop, logo);
|
||||
|
||||
assert.equal(widget.enabled, false, 'runtime position must not be overwritten by the native Widget');
|
||||
assert.equal(stop.position.x, 665); assert.equal(stop.position.z, 7);
|
||||
assert.equal(stop.position.y, 198, 'button top is 12 px below the logo bottom in Canvas coordinates');
|
||||
});
|
||||
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.ts')) return { resolveLaunchWaitTime };
|
||||
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<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 {}, 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.ts')) return { resolveLaunchWaitTime };
|
||||
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<number, () => 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.ts')) return { resolveLaunchWaitTime };
|
||||
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(emitDraw = true) {
|
||||
const component = new exports.LocalPlatformLogin(); const f = fixture();
|
||||
const stopButton = new Node('StopButton'), canvas = new Node('Canvas');
|
||||
stopButton.parent = canvas; stopButton.components.set(UITransform, new UITransform());
|
||||
canvas.components.set(UITransform, new UITransform());
|
||||
Object.assign(component, f.refs, { stopButton, 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());
|
||||
const button = new Node('Spirit3103'), text = new Node('Spirit3104');
|
||||
for (const node of [button, text, group]) node.components.set(UITransform, new UITransform());
|
||||
const label = new Label() as Label & { node: Node }; label.node = text; text.components.set(Label, label); text.parent = group;
|
||||
group.children.push(bar, button, text);
|
||||
logo.children.push(group, new Node('group-32')); component.loadingRoot.children.push(logo);
|
||||
component.loadingRoot.getComponentsInChildren = () => [];
|
||||
await component.start(); if (emitDraw) 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;
|
||||
hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'launchWaitTime' ? 500 : null } });
|
||||
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();
|
||||
hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'launchWaitTime' ? 500 : null } });
|
||||
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);
|
||||
|
||||
const undrawn = await started(false); let zeroComplete = false;
|
||||
hostOptions.onConfigResolved({ identity: { version: 731 }, remoteConfig: { getValue: (name: string) => name === 'hall_config' ? { launchWaitTime: 0 } : null } });
|
||||
const zeroMinimum = hostOptions.waitForMinimumDisplay().then(() => { zeroComplete = true; });
|
||||
await drain(); assert.equal(zeroComplete, false, 'zero display deadline cannot bypass first native frame');
|
||||
director.emit('draw'); await zeroMinimum; assert.equal(zeroComplete, true); undrawn.stop();
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import { resolveLocalStartup } from '../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
import { RECONNECT_INTERVAL_MS } from '../../YouleNexus/assets/framework/core/constants.ts';
|
||||
import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
||||
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
||||
import { FakeTransport } from '../helpers/fake-transport.ts';
|
||||
|
||||
const login: OutboundEnvelope = { app: 'youle', route: 'agent', rpc: 'player_login', data: { gameid: 101 } };
|
||||
function fixture(name: string) {
|
||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||
}
|
||||
async function setup(recordHook?: (event: { kind: string; data: unknown }) => void) {
|
||||
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts')
|
||||
.catch(error => assert.fail(`Local login Wire implementation must exist: ${String(error)}`));
|
||||
const context = await resolveLocalStartup({ hostKind: 'h5', win: {}, search: '?profile=local',
|
||||
fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } } });
|
||||
const transports: FakeTransport[] = [];
|
||||
const events: WireEvent[] = [];
|
||||
const evidence: { kind: string; data: unknown }[] = [];
|
||||
const faults: unknown[] = [];
|
||||
const order: string[] = [];
|
||||
const wire = api.createLocalLoginWire({ context,
|
||||
transportFactory: () => { const transport = new FakeTransport(); transports.push(transport); return transport; },
|
||||
record: event => { evidence.push(event); recordHook?.(event); },
|
||||
onFault: error => { order.push('fault'); faults.push(error); assert.equal(transports[0]?.closeCalls, 1); },
|
||||
});
|
||||
wire.subscribe(event => { events.push(event); order.push(event.type === 'message' ? event.message.rpc : event.type); });
|
||||
const push = async (message: unknown) => { transports[0]!.serverPush(JSON.stringify(message)); await transports[0]!.flush(); };
|
||||
return { wire, context, transports, events, evidence, faults, order, push };
|
||||
}
|
||||
|
||||
test('start is not open; only original lobby and ordinary agent/platform events are forwarded', async t => {
|
||||
const h = await setup(); t.after(() => h.wire.stop());
|
||||
h.wire.start();
|
||||
assert.equal(h.events.length, 0);
|
||||
await h.transports[0]!.flush();
|
||||
let unsubscribed = 0;
|
||||
const unsubscribe = h.wire.subscribe(() => unsubscribed++); unsubscribe(); unsubscribe();
|
||||
await h.push(fixture('player-login-success.json'));
|
||||
await h.push({ route: 'agent', rpc: 'show_message', data: { msg: 'hello' } });
|
||||
await h.push({ route: 'platform', rpc: 'notice', data: {} });
|
||||
assert.equal(unsubscribed, 0);
|
||||
assert.equal(h.events.filter(event => event.type === 'message').length, 3);
|
||||
for (const event of h.events) assert.ok(h.evidence.some(item => item.kind === 'wire-event' && item.data === event));
|
||||
assert.deepEqual(h.faults, []);
|
||||
});
|
||||
|
||||
for (const [name, message, error] of [
|
||||
['room recovery', fixture('player-login-room.json'), /login room recovery/],
|
||||
['malformed login', { route: 'agent', rpc: 'player_login', data: { state: 0 } }, /\$\.data\.playerid/],
|
||||
['room route', { route: 'room', rpc: 'notice', data: {} }, /room/],
|
||||
['game route', { route: 'mahjong', rpc: 'notice', data: {} }, /mahjong/],
|
||||
['agent switch', { route: 'agent', rpc: 'connect_agentserver', data: {} }, /connect_agentserver/],
|
||||
['platform switch', { route: 'platform', rpc: 'connect_roomserver', data: {} }, /connect_roomserver/],
|
||||
] as const) test(`${name} stops before downstream delivery and remains terminal`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||
await h.push(message);
|
||||
assert.equal(h.faults.length, 1); assert.match(String(h.faults[0]), error);
|
||||
assert.equal(h.events.length, 1);
|
||||
await h.push(fixture('player-login-success.json'));
|
||||
assert.throws(() => h.wire.reconnectCurrent(), /stopped|terminal/);
|
||||
assert.throws(() => h.wire.start(), /stopped|terminal/);
|
||||
h.wire.stop(); h.wire.stop(); t.mock.timers.tick(60000);
|
||||
assert.equal(h.transports.length, 1); assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1);
|
||||
});
|
||||
|
||||
for (const message of [
|
||||
{ route: 'agent', rpc: 'player_login', data: { state: 1 } },
|
||||
{ route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } },
|
||||
]) test(`${message.rpc} failure reaches existing handlers before host notification`, async t => {
|
||||
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||
h.wire.subscribe(event => { if (event.type === 'message') h.wire.stop(); });
|
||||
await h.push(message);
|
||||
assert.deepEqual(h.order, ['open', message.rpc, 'fault']);
|
||||
assert.equal(h.faults.length, 1);
|
||||
});
|
||||
|
||||
test('outbound scope and explicit switch fail without sending or reconnecting', async t => {
|
||||
for (const operation of ['send', 'switch'] as const) {
|
||||
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||
assert.throws(() => operation === 'send'
|
||||
? h.wire.send({ ...login, rpc: 'create_room' }) : h.wire.switchServer(h.context.config.servers[0]!), /scope|switchServer/);
|
||||
assert.deepEqual(h.transports[0]!.sent, []); assert.equal(h.faults.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('normal network close reconnects with the same single login envelope', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = await setup(); t.after(() => h.wire.stop());
|
||||
h.wire.subscribe(event => { if (event.type === 'open') h.wire.send(login); });
|
||||
h.wire.start(); await h.transports[0]!.flush();
|
||||
h.transports[0]!.serverError(); await h.transports[0]!.flush(); t.mock.timers.tick(RECONNECT_INTERVAL_MS);
|
||||
assert.equal(h.transports.length, 2); await h.transports[1]!.flush();
|
||||
assert.equal(h.transports[0]!.sent[0], JSON.stringify(login));
|
||||
assert.deepEqual(h.transports[1]!.sent, h.transports[0]!.sent); assert.deepEqual(h.faults, []);
|
||||
});
|
||||
|
||||
test('scope fault retains its primary error and independent cleanup error', async t => {
|
||||
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||
const cleanup = new Error('cleanup failed');
|
||||
h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; };
|
||||
await h.push({ route: 'room', rpc: 'notice', data: {} });
|
||||
assert.equal(h.faults.length, 1);
|
||||
const errors = (h.faults[0] as { errors: unknown[] }).errors;
|
||||
assert.match(String(errors[0]), /room/); assert.equal(errors[1], cleanup);
|
||||
h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1);
|
||||
});
|
||||
|
||||
test('evidence failure preserves the exact thrown value plus cleanup error and prevents delivery', async t => {
|
||||
const primary = { reason: 'evidence unavailable' };
|
||||
const h = await setup(event => {
|
||||
if (event.kind === 'wire-event' && (event.data as WireEvent).type === 'message') throw primary;
|
||||
});
|
||||
t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||
const cleanup = new Error('close failed');
|
||||
h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; };
|
||||
await h.push(fixture('player-login-success.json'));
|
||||
assert.deepEqual((h.faults[0] as { errors: unknown[] }).errors, [primary, cleanup]);
|
||||
assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1);
|
||||
});
|
||||
|
||||
test('listener error is reported unchanged, stops remaining delivery, and stops only once', async t => {
|
||||
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||
const primary = new Error('downstream failed');
|
||||
h.wire.subscribe(() => { throw primary; });
|
||||
let laterDeliveries = 0; h.wire.subscribe(() => laterDeliveries++);
|
||||
await h.push(fixture('player-login-success.json'));
|
||||
assert.equal(h.faults[0], primary); assert.equal(laterDeliveries, 0);
|
||||
h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1);
|
||||
});
|
||||
|
||||
test('terminal callback cannot send or reconnect before runtime stop; host notification is single', async t => {
|
||||
const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush();
|
||||
h.wire.subscribe(event => {
|
||||
if (event.type !== 'message') return;
|
||||
assert.throws(() => h.wire.send(login), /terminal/);
|
||||
assert.throws(() => h.wire.reconnectCurrent(), /terminal/);
|
||||
});
|
||||
await h.push({ route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } });
|
||||
assert.equal(h.faults.length, 1); assert.deepEqual(h.transports[0]!.sent, []);
|
||||
});
|
||||
+2
-2
@@ -4,10 +4,10 @@ import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/g
|
||||
import type { GameHost, PlatformToGameEvent, GameServerMessage } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||
|
||||
async function setup() {
|
||||
const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-game-entry.ts')
|
||||
const api = await import('../../YouleNexus/assets/scripts/platform-login/login-stage-game-entry.ts')
|
||||
.catch(error => assert.fail(`Diagnostic GameEntry implementation must exist: ${String(error)}`));
|
||||
const evidence: { kind: string; data: unknown }[] = [];
|
||||
return { entry: api.createDiagnosticGameEntry(101, event => evidence.push(event)), evidence };
|
||||
return { entry: api.createLoginStageGameEntry(101, event => evidence.push(event)), evidence };
|
||||
}
|
||||
|
||||
test('real GameSessionHost validates and releases one diagnostic instance without entering a game', async () => {
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
test('login busy state starts Animation once, hides on completion and restarts on retry', () => {
|
||||
const exports: any = {};
|
||||
const source = readFileSync(new URL('../../YouleNexus/assets/scripts/platform-login/login-panel.ts', import.meta.url), 'utf8');
|
||||
runInNewContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText, { exports, require(name: string) {
|
||||
if (name === './node-bindings.ts') return { requireNode(root: any, path: string) {
|
||||
const result = root.paths[path]; if (!result) throw Error(`Missing ${path}`); return result;
|
||||
} };
|
||||
return {};
|
||||
} });
|
||||
const children = Object.fromEntries(['登录中遮罩','登录头像背景框','登录中头像','登录中昵称','登录中动画','登录中头像框']
|
||||
.map(name => [name, {active:false}]));
|
||||
const group = {active:false, paths:children};
|
||||
let playing = false, starts = 0, stops = 0;
|
||||
const panel = Object.create(exports.LoginPanel.prototype);
|
||||
panel.root = {paths:{'group-2/Logining':group}};
|
||||
panel.loadingAnimation = {defaultClip:{name:'LoginLoading'}, getState:()=>({isPlaying:playing}),
|
||||
play(name: string){assert.equal(name,'LoginLoading');playing=true;starts++;}, stop(){playing=false;stops++;}};
|
||||
panel.setBusy(true); panel.setBusy(true);
|
||||
assert.equal(starts,1); assert.equal(group.active,true);
|
||||
assert.ok(Object.values(children).every(node=>node.active));
|
||||
panel.setBusy(false);
|
||||
assert.equal(stops,1); assert.equal(group.active,false); assert.equal(playing,false);
|
||||
panel.setBusy(true); assert.equal(starts,2);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { LoginAccounts, nicknameHash } from '../../YouleNexus/assets/framework/adapters/native/login-accounts.ts';
|
||||
import { resolveLoginPolicy } from '../../YouleNexus/assets/framework/presentation/login-policy.ts';
|
||||
import { buildLoginRequest } from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.ts';
|
||||
test('native authorization preserves provider numbers, agreement guard, callback and cache', () => {
|
||||
const values=new Map<string,string>();const calls:number[]=[];const submitted:any[]=[];const tips:string[]=[];
|
||||
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
|
||||
settings:{accreditlogin:p=>calls.push(p),getcompareCode:()=>0},random:()=>0.2,now:()=>100,
|
||||
gameid:'g',agentid:'a',policy:resolveLoginPolicy({raw:{},getValue:()=>null}),submit:(a,d)=>submitted.push([a,d]),tip:t=>tips.push(t)});
|
||||
service.setAgreement(false);service.authorize('wechat');assert.deepEqual(calls,[]);assert.deepEqual(tips,['未同意协议']);
|
||||
service.setAgreement(true);service.authorize('wechat');service.authorize('qq');assert.deepEqual(calls,[2,1]);
|
||||
service.acceptNative({openid:'o',headimgurl:'a',nickname:'n',sex:1,city:'c',Province:'p',unionid:'u'},'bridge');
|
||||
assert.equal(submitted[0][0].avatar,'a');assert.equal(submitted[0][0].province,'p');
|
||||
assert.ok(values.has('tsgame_wxinfo'));assert.equal(service.cached()?.nickname,'n');
|
||||
});
|
||||
test('all login paths require the current agreement and rejected paths have no side effects',()=>{
|
||||
const values=new Map<string,string>(),tips:string[]=[],submitted:unknown[]=[],native:number[]=[];
|
||||
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
|
||||
settings:{getcompareCode:()=>0,accreditlogin:p=>native.push(p)},random:()=>0.2,now:()=>100,
|
||||
gameid:'g',agentid:'a',policy:resolveLoginPolicy({raw:{},getValue:()=>null}),submit:a=>submitted.push(a),tip:t=>tips.push(t)});
|
||||
assert.equal(service.agreed,true);service.setAgreement(false);
|
||||
service.authorize('wechat');service.authorize('qq');service.visitor();service.device();service.phone('00123','000456');service.autoLogin();
|
||||
service.acceptNative({},'global');service.acceptNative({},'bridge');
|
||||
for(let i=0;i<5;i++)service.hiddenFirst();service.hiddenSecond();
|
||||
assert.equal(tips.length,9);assert.ok(tips.every(t=>t==='未同意协议'));
|
||||
assert.equal(values.size,0);assert.equal(submitted.length,0);assert.equal(native.length,0);
|
||||
service.setAgreement(true);service.visitor();assert.equal(submitted.length,1);
|
||||
});
|
||||
test('successful device login persists server identity for the next automatic login', () => {
|
||||
const values=new Map<string,string>();const submitted:any[]=[];
|
||||
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
|
||||
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),deviceLogin:true},
|
||||
submit:(a,d)=>submitted.push([a,d]),tip:()=>{}});
|
||||
service.loginSucceeded({state:0,playerid:42,openid:'server-o',avatar:'server-a',nickname:'server-n',sex:2,city:'c',province:'p',unionid:'u',tel:'13800138000'}, {enabled:true,telphone:'13800138000'});
|
||||
service.autoLogin();assert.equal(submitted[0][0].openid,'server-o');
|
||||
assert.deepEqual(submitted[0][1],{enabled:true,telphone:'13800138000'});
|
||||
assert.equal(values.get('tsgame_playerid_ga'),'42');assert.equal(values.get('tsgame_dLogin'),'1');
|
||||
});
|
||||
test('visitor login and cached visitor stay phone-free when both login buttons are enabled', () => {
|
||||
const values=new Map<string,string>();const submitted:any[]=[];
|
||||
const options={storage:{getItem:(k:string)=>values.get(k)??null,setItem:(k:string,v:string)=>{values.set(k,v);}},
|
||||
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),showVisitor:true,deviceLogin:true},
|
||||
submit:(a:unknown,d:unknown)=>submitted.push([a,d]),tip:()=>{}};
|
||||
const service=new LoginAccounts(options);
|
||||
service.visitor();assert.deepEqual(submitted[0][1],{enabled:false});
|
||||
assert.doesNotThrow(()=>service.loginSucceeded({state:0,playerid:42},submitted[0][1]));
|
||||
assert.equal(values.get('tsgame_playerid_ga'),'42');
|
||||
assert.equal(values.has('tsgame_devicePlayer'),false);assert.equal(values.has('tsgame_dLogin'),false);
|
||||
const reloaded=new LoginAccounts(options);reloaded.autoLogin();
|
||||
assert.deepEqual(submitted[1],[submitted[0][0],{enabled:false}]);
|
||||
assert.doesNotThrow(()=>reloaded.loginSucceeded({state:0,playerid:42},submitted[1][1]));
|
||||
});
|
||||
test('global and WVJB authorization retain their distinct legacy cache destinations', () => {
|
||||
const values=new Map<string,string>();
|
||||
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
|
||||
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),showVisitor:true},submit:()=>{},tip:()=>{}});
|
||||
const data={openid:'o',headimgurl:'a',nickname:'n',sex:1,city:'c',Province:'p',unionid:'u'};
|
||||
service.acceptNative(data,'global');assert.ok(values.has('tsgame_visitorinfo'));assert.equal(values.has('tsgame_wxinfo'),false);
|
||||
service.acceptNative(data,'bridge');assert.ok(values.has('tsgame_wxinfo'));
|
||||
});
|
||||
test('legacy nickname MD5 matches standard vectors',()=>{
|
||||
assert.equal(nicknameHash(''),'d41d8cd98f00b204e9800998ecf8427e');
|
||||
assert.equal(nicknameHash('abc'),'900150983cd24fb0d6963f7d28e17f72');
|
||||
});
|
||||
test('phone login emits original sms fields and does not masquerade as cached auto login', () => {
|
||||
const values=new Map<string,string>();const submitted:any[]=[];
|
||||
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
|
||||
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:resolveLoginPolicy({raw:{},getValue:()=>null}),
|
||||
submit:(a,d)=>submitted.push([a,d]),tip:()=>{}});
|
||||
service.phone('13800138000','123456');
|
||||
const [account,mode]=submitted[0];assert.equal(account.unionid,'phone_13800138000');
|
||||
const result=buildLoginRequest({identity:{agentid:'a',gameid:'g',channelid:'c',marketid:4,version:1}},account,
|
||||
{location:null,machineid:'m',machineroom:'',deviceLogin:mode,loginPlayerId:{enabled:false}});
|
||||
assert.equal(result.data.telphone,'13800138000');assert.equal(result.data.smmcode,'123456');assert.equal(result.data.telphoneAuto,false);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveLoginPolicy, loginButtons } from '../../YouleNexus/assets/framework/presentation/login-policy.ts';
|
||||
import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||
|
||||
const remote = (values: Record<string, unknown>) => ({raw:values,getValue:(key:string)=>values[key] ?? null});
|
||||
test('source defaults and channel overrides retain legacy button order', () => {
|
||||
const policy = resolveLoginPolicy(remote({}));
|
||||
assert.deepEqual(loginButtons(policy, 0, false), {wechat:true,visitor:false,phone:false,quick:false,qq:false,account:false});
|
||||
assert.deepEqual(loginButtons(policy, 1, false), {wechat:true,visitor:true,phone:false,quick:false,qq:false,account:false});
|
||||
const configured = resolveLoginPolicy(remote({deviceLogin:1,hall_config:{showVisitor:1,qqLogin:1}}));
|
||||
assert.deepEqual(loginButtons(configured, 0, false), {wechat:false,visitor:true,phone:true,quick:false,qq:true,account:false});
|
||||
assert.deepEqual(loginButtons(configured, 0, true), {wechat:false,visitor:true,phone:false,quick:false,qq:true,account:true});
|
||||
});
|
||||
test('invalid upstream login options fail at the config source', () => {
|
||||
assert.throws(()=>resolveLoginPolicy(remote({deviceLogin:'yes'})), /deviceLogin/);
|
||||
assert.throws(()=>resolveLoginPolicy(remote({hall_config:{qqLogin:[]}})), /qqLogin/);
|
||||
});
|
||||
|
||||
test('build identity selects its own remote agent showVisitor through to login buttons', async () => {
|
||||
// Relevant hierarchy captured from config_test/update_jsonv2.txt on 2026-09-06.
|
||||
const config = {agentlist:[
|
||||
{agentid:'veRa0qrBf0df2K1G4de2tgfmVxB2jxpv',gamelist:[{gameid:'G2hw0ubng0zcoI0r4mx3H2yr4GejidwO'}]},
|
||||
{agentid:'1B2h0ccl205c390Y28m1Ajdplkuu4wgy',showVisitor:1,gamelist:[
|
||||
{gameid:'J9r4022t80pxlb01zOK76DN1WD4QewFl',channellist:[
|
||||
{channelid:'aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG',marketlist:[{marketid:4,deviceLogin:true}]}]}]},
|
||||
]};
|
||||
const options={startupConfig:{...STARTUP_CONFIG,configSource:'remote' as const},hostKind:'h5' as const,win:{},search:'',fetcher:{fetch:async()=>config}};
|
||||
const resolved=await resolveRuntimeConfig(options);
|
||||
assert.equal(resolved.remoteConfig.getValue('showVisitor'),1);
|
||||
assert.equal(resolved.identity.channelid,'aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG');
|
||||
const buttons=loginButtons(resolveLoginPolicy(resolved.remoteConfig),0,false);
|
||||
assert.equal(buttons.visitor,true);assert.equal(buttons.wechat,false);
|
||||
assert.equal(buttons.phone,true,'market deviceLogin and agent showVisitor both take effect');
|
||||
const other=await resolveRuntimeConfig({...options,search:'?agentid=veRa0qrBf0df2K1G4de2tgfmVxB2jxpv'});
|
||||
assert.equal(resolveLoginPolicy(other.remoteConfig).showVisitor,false,'must not read another agent configuration');
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
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<unknown, unknown>();
|
||||
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<number, { at: number; callback: () => 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);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readPhoneCode } from '../../YouleNexus/assets/framework/adapters/native/phone-code.ts';
|
||||
|
||||
test('original phone cache restores the number and 30-second remainder without auto-filling the code',()=>{
|
||||
const raw=JSON.stringify({phoneNumber:'0013800138000',code:'1234',codeTime:1000});
|
||||
assert.deepEqual(readPhoneCode(raw,2500),{phoneNumber:'0013800138000',restTime:28});
|
||||
assert.deepEqual(readPhoneCode(raw,31000),{phoneNumber:'0013800138000',restTime:0});
|
||||
assert.deepEqual(readPhoneCode(raw,32000),{phoneNumber:'0013800138000',restTime:0});
|
||||
assert.equal(readPhoneCode(null,32000),null);
|
||||
});
|
||||
test('malformed phone cache exposes the source error',()=>{
|
||||
assert.throws(()=>readPhoneCode('{',100),SyntaxError);
|
||||
assert.throws(()=>readPhoneCode(JSON.stringify({phoneNumber:'123',codeTime:'bad'}),100),/codeTime/);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SceneFlow } from '../../YouleNexus/assets/scripts/platform-login/scene-flow.ts';
|
||||
test('scene transitions are sequential and disposal prevents late scene binding',async()=>{
|
||||
const calls:string[]=[];let finish!:()=>void;
|
||||
const flow=new SceneFlow(async name=>{calls.push(name);if(name==='Loading')await new Promise<void>(r=>finish=r);});
|
||||
const loading=flow.enter('Loading',()=>calls.push('bindLoading'));
|
||||
const login=flow.enter('Login',()=>calls.push('bindLogin'));
|
||||
await Promise.resolve();assert.deepEqual(calls,['Loading']);finish();await loading;await login;
|
||||
assert.deepEqual(calls,['Loading','bindLoading','Login','bindLogin']);
|
||||
flow.dispose();await assert.rejects(flow.enter('Login',()=>{}),/disposed/);
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { remoteFixture } from '../fixtures/remote-config.ts';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { remoteFixture } from '../fixtures/remote-config.ts';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { remoteFixture } from '../fixtures/remote-config.ts';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { remoteFixture } from '../fixtures/remote-config.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -131,4 +133,4 @@ test('StartupOrchestrator: bootstrap 可独立调用(不依赖 onLogin)', async
|
||||
ready.then(() => { resolved = true; });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(resolved, false, '仅 bootstrap 不足以触发 ready');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
|
||||
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
|
||||
import { remoteFixture } from '../fixtures/remote-config.ts';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
Reference in New Issue
Block a user