import { test } from 'node:test'; import assert from 'node:assert/strict'; import { WireClient, type WireEvent, } from '../../YouleNexus/assets/framework/net/wire-client.ts'; import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts'; import { FakeTransport } from '../helpers/fake-transport.ts'; function fakeClock() { let sequence = 0; let now = 0; const timers = new Map void; readonly at: number }>(); const clock: Clock = { setTimeout(fn, ms) { const id = ++sequence; timers.set(id, { fn, at: now + ms }); return id; }, clearTimeout(handle) { timers.delete(handle as number); }, }; const advance = (ms: number) => { now += ms; while (true) { const due = [...timers.entries()] .filter(([, timer]) => timer.at <= now) .sort(([leftId, left], [rightId, right]) => left.at - right.at || leftId - rightId)[0]; if (!due) return; timers.delete(due[0]); due[1].fn(); } }; return { clock, advance, activeTimers: () => timers.size }; } function makeHarness(servers: readonly string[] = ['ws://a']) { const transports: FakeTransport[] = []; const timer = fakeClock(); const client = new WireClient({ servers, clock: timer.clock, transportFactory: () => { const transport = new FakeTransport(); transports.push(transport); return transport; }, }); return { client, transports, ...timer }; } const envelope: OutboundEnvelope = { app: 'youle', route: 'agent', rpc: 'player_get_roomlist', data: { gameid: 101 }, }; test('start emits the opened server and never sends login automatically', async () => { const { client, transports } = makeHarness(); const events: WireEvent[] = []; client.subscribe((event) => events.push(event)); client.start(); await transports[0]!.flush(); assert.deepEqual(events, [{ type: 'open', server: 'ws://a' }]); assert.equal(transports[0]!.sent.length, 0, 'open 不应由 WireClient 自动发 login'); }); test('all decoded business RPCs are emitted exactly once without interception', async () => { const { client, transports } = makeHarness(); const events: WireEvent[] = []; client.subscribe((event) => events.push(event)); client.start(); const transport = transports[0]!; await transport.flush(); const messages = [ { route: 'agent', rpc: 'player_login', data: { state: 0 } }, { route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } }, { route: 'room', rpc: 'connect_roomserver', data: { roomserver: 'ws://room' } }, { route: 'agent', rpc: 'connect_agentserver', data: { agentserver: 'ws://agent', opt: 'free_room' } }, ]; for (const message of messages) transport.serverPush(JSON.stringify(message)); await transport.flush(); assert.deepEqual(events.slice(1), messages.map((message) => ({ type: 'message', message }))); assert.equal(transport.sent.length, 0); }); test('handshake, heartbeat, server-down and invalid frames are not business messages', async () => { const { client, transports, advance } = makeHarness(); const events: WireEvent[] = []; client.subscribe((event) => events.push(event)); client.start(); const transport = transports[0]!; await transport.flush(); transport.serverPush('@toconconABC'); transport.serverPush(JSON.stringify({ com: '@serverheartbeat' })); transport.serverPush('webserve-服务器未工作'); transport.serverPush('not json'); await transport.flush(); assert.deepEqual(events, [{ type: 'open', server: 'ws://a' }]); assert.equal(transport.sent.length, 0); advance(29999); assert.equal(events.some((event) => event.type === 'slow'), false); advance(1); assert.equal(events.filter((event) => event.type === 'slow').length, 1); }); test('send requires an open connection and serializes the completed envelope once', async () => { const { client, transports } = makeHarness(); assert.throws(() => client.send(envelope), /connection.*open/i); client.start(); const transport = transports[0]!; assert.throws(() => client.send(envelope), /connection.*open/i); await transport.flush(); client.send(envelope); assert.deepEqual(transport.sent.map((frame) => JSON.parse(frame)), [envelope]); client.stop(); assert.throws(() => client.send(envelope), /connection.*open/i); }); test('constructor rejects an empty validated server list and start cannot run twice', () => { assert.throws( () => new WireClient({ servers: [], transportFactory: () => new FakeTransport() }), /servers.*non-empty/i, ); const { client } = makeHarness(); client.start(); assert.throws(() => client.start(), /already.*started/i); }); test('switchServer validates the target before closing and reconnects to it after policy delay', async () => { const { client, transports, advance } = makeHarness(['ws://a', 'ws://b']); const events: WireEvent[] = []; client.subscribe((event) => events.push(event)); client.start(); const first = transports[0]!; await first.flush(); assert.throws(() => client.switchServer('http://room'), /WebSocket.*URL/i); assert.throws(() => client.switchServer('ws://'), /WebSocket.*URL/i); assert.equal(first.closeCalls, 0); client.switchServer('ws://room'); assert.equal(first.closeCalls, 1); assert.deepEqual(events.slice(-2), [ { type: 'close' }, { type: 'reconnecting', server: 'ws://room' }, ]); advance(9999); assert.equal(transports.length, 1); advance(1); assert.equal(transports[1]!.url, 'ws://room'); await transports[1]!.flush(); assert.deepEqual(events[events.length - 1], { type: 'open', server: 'ws://room' }); }); test('reconnectCurrent closes once and reconnects to the current target after 10 seconds', async () => { const { client, transports, advance } = makeHarness(['ws://a', 'ws://b']); const events: WireEvent[] = []; client.subscribe((event) => events.push(event)); client.start(); await transports[0]!.flush(); client.reconnectCurrent(); assert.equal(transports[0]!.closeCalls, 1); assert.deepEqual(events.slice(-2), [ { type: 'close' }, { type: 'reconnecting', server: 'ws://a' }, ]); advance(10000); assert.equal(transports[1]!.url, 'ws://a'); }); test('duplicate transport close/error is handled once and failures rotate every three attempts', async () => { const { client, transports, advance, activeTimers } = makeHarness(['ws://a', 'ws://b']); const reconnectTargets: string[] = []; client.subscribe((event) => { if (event.type === 'reconnecting') reconnectTargets.push(event.server); }); client.start(); await transports[0]!.flush(); transports[0]!.serverClose(); await transports[0]!.flush(); assert.deepEqual(reconnectTargets, ['ws://a']); advance(10000); await transports[1]!.flush(); transports[1]!.serverError(); await transports[1]!.flush(); assert.deepEqual(reconnectTargets, ['ws://a', 'ws://a']); advance(10000); await transports[2]!.flush(); transports[2]!.serverError(); transports[2]!.serverClose(); await transports[2]!.flush(); assert.deepEqual(reconnectTargets, ['ws://a', 'ws://a', 'ws://b']); assert.equal(activeTimers(), 1); advance(10000); assert.equal(transports.length, 4); assert.equal(transports[3]!.url, 'ws://b'); }); test('stale transport generations cannot emit messages or opens', async () => { const { client, transports, advance } = makeHarness(); const events: WireEvent[] = []; client.subscribe((event) => events.push(event)); client.start(); const first = transports[0]!; await first.flush(); first.serverClose(); await first.flush(); first.serverPush(JSON.stringify({ route: 'agent', rpc: 'stale_before_reconnect', data: {} })); await first.flush(); advance(10000); first.serverPush(JSON.stringify({ route: 'agent', rpc: 'stale_after_reconnect', data: {} })); await first.flush(); await transports[1]!.flush(); assert.deepEqual(events.filter((event) => event.type === 'message'), []); assert.equal(events.filter((event) => event.type === 'open').length, 2); }); test('unsubscribe removes the listener without affecting transport lifecycle', async () => { const { client, transports } = makeHarness(); const events: WireEvent[] = []; const unsubscribe = client.subscribe((event) => events.push(event)); client.start(); const transport = transports[0]!; await transport.flush(); unsubscribe(); unsubscribe(); transport.serverPush(JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } })); await transport.flush(); assert.deepEqual(events, [{ type: 'open', server: 'ws://a' }]); }); test('stop is idempotent, closes an active transport once and clears its watchdog', async () => { const { client, transports, advance, activeTimers } = makeHarness(); const events: WireEvent[] = []; client.subscribe((event) => events.push(event)); client.start(); const first = transports[0]!; await first.flush(); first.serverPush('@toconconABC'); await first.flush(); assert.equal(activeTimers(), 1); client.stop(); client.stop(); assert.equal(activeTimers(), 0); assert.equal(first.closeCalls, 1); advance(60000); await first.flush(); assert.equal(transports.length, 1); assert.equal(events.filter((event) => event.type === 'close').length, 1); assert.throws(() => client.send(envelope), /connection.*open/i); }); test('stop clears a pending reconnect timer and prevents a later transport generation', async () => { const { client, transports, advance, activeTimers } = makeHarness(); client.start(); const first = transports[0]!; await first.flush(); client.reconnectCurrent(); assert.equal(first.closeCalls, 1); assert.equal(activeTimers(), 1); client.stop(); assert.equal(activeTimers(), 0); advance(60000); await first.flush(); assert.equal(transports.length, 1); });