import { test } from 'node:test' import assert from 'node:assert/strict' import { RealtimeClient } from '../realtime' class FakeSocket { static readonly CONNECTING = 0 static readonly OPEN = 1 static readonly CLOSED = 3 static instances: FakeSocket[] = [] readyState = FakeSocket.CONNECTING onopen: (() => void) | null = null onmessage: ((event: { data: unknown }) => void) | null = null onerror: (() => void) | null = null onclose: ((event: { code: number }) => void) | null = null readonly url: string readonly sent: string[] = [] constructor(url: string) { this.url = url FakeSocket.instances.push(this) } send(data: string): void { this.sent.push(data) } close(): void { this.readyState = FakeSocket.CLOSED this.onclose?.({ code: 1006 }) } open(): void { this.readyState = FakeSocket.OPEN this.onopen?.() } emit(event: string, data: unknown): void { this.onmessage?.({ data: JSON.stringify({ event, data }) }) } drop(code: number): void { this.readyState = FakeSocket.CLOSED this.onclose?.({ code }) } } function installFakeWebSocket(): void { FakeSocket.instances = [] ;(globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket } function firstSocket(): FakeSocket { const socket = FakeSocket.instances[0] assert.ok(socket) return socket } // P0-1:服务端在客户端版本超前(换库 / 还原备份后 change_log 归零)时也会发快照, // 若按版本号丢弃,大屏会永久冻结在旧数据上。 test('快照版本低于本地缓存也接受,并把版本重置到快照', () => { installFakeWebSocket() const seen: number[] = [] const client = new RealtimeClient({ role: 'display', url: 'ws://test/ws', initialVersion: 500, onSnapshot: (snapshot) => seen.push(snapshot.version), }) client.connect() firstSocket().open() firstSocket().emit('snapshot', { version: 3 }) assert.deepEqual(seen, [3]) assert.equal(client.version, 3) client.close() }) // P1-22:Console 的「已连接大屏实例」靠 hello 里的 page / instanceId 定位。 test('hello 上报 page 与 instanceId,翻页后补发', () => { installFakeWebSocket() const client = new RealtimeClient({ role: 'display', url: 'ws://test/ws', instanceId: 'screen-1', page: 'standings', }) client.connect() const socket = firstSocket() socket.open() assert.deepEqual(JSON.parse(socket.sent[0] ?? '{}'), { event: 'hello', data: { role: 'display', instanceId: 'screen-1', page: 'standings' }, }) socket.sent.length = 0 client.setPage('team-medals') assert.equal(JSON.parse(socket.sent[0] ?? '{}').data.page, 'team-medals') client.close() }) // P1-17:策略性拒绝(1008)重连也会被同样拒绝,必须停重连并提示登录。 test('close 1008 停止重连并回调 onAuthRequired', async () => { installFakeWebSocket() let authRequired = 0 const client = new RealtimeClient({ role: 'admin', url: 'ws://test/ws', reconnect: { baseDelayMs: 5, maxDelayMs: 5, jitterRatio: 0 }, onAuthRequired: () => { authRequired += 1 }, }) client.connect() const socket = firstSocket() socket.open() socket.drop(1008) assert.equal(authRequired, 1) assert.equal(client.status, 'offline') await new Promise((resolve) => setTimeout(resolve, 30)) assert.equal(FakeSocket.instances.length, 1) client.close() }) // P1-34:半开握手不触发 onopen/onclose,必须靠握手超时判死。 test('握手超时主动断开并重连', async () => { installFakeWebSocket() const errors: string[] = [] const client = new RealtimeClient({ role: 'display', url: 'ws://test/ws', handshakeMs: 10, reconnect: { baseDelayMs: 5, maxDelayMs: 5, jitterRatio: 0 }, onError: (error) => errors.push(error.message), }) client.connect() await new Promise((resolve) => setTimeout(resolve, 40)) assert.ok(errors.some((message) => message.includes('握手超时'))) assert.ok(FakeSocket.instances.length >= 2) client.close() }) // P2-13:没有快照基准时不能直接合并 catchup。 test('没有快照基准时收到 catchup 先请求全量', () => { installFakeWebSocket() const catchups: unknown[] = [] const client = new RealtimeClient({ role: 'display', url: 'ws://test/ws', onCatchup: (message) => catchups.push(message), }) client.connect() const socket = firstSocket() socket.open() socket.sent.length = 0 socket.emit('catchup', { fromVersion: 0, version: 5, changes: [] }) assert.equal(catchups.length, 0) assert.ok(socket.sent.some((raw) => raw.includes('"resync"'))) client.close() })