515 tests across 38 files. Branch coverage 88%, function coverage 83% on testable logic (stores, composables, api, utils, services, router). New test files: websocket, useLoginSounds, useMobileBackButton, useControllerNav, routes. Extended: rpc-client (99.5%), container store (100%). Fixed: useNavSounds AudioContext mock, type errors across tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
262 lines
7.8 KiB
TypeScript
262 lines
7.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
// Mock fast-json-patch
|
|
vi.mock('fast-json-patch', () => ({
|
|
applyPatch: vi.fn((doc: unknown, _ops: unknown[]) => ({
|
|
newDocument: { ...doc as Record<string, unknown>, patched: true },
|
|
})),
|
|
}))
|
|
|
|
// Mock WebSocket
|
|
class MockWebSocket {
|
|
static CONNECTING = 0
|
|
static OPEN = 1
|
|
static CLOSING = 2
|
|
static CLOSED = 3
|
|
|
|
readyState = MockWebSocket.CONNECTING
|
|
onopen: ((ev: Event) => void) | null = null
|
|
onclose: ((ev: CloseEvent) => void) | null = null
|
|
onerror: ((ev: Event) => void) | null = null
|
|
onmessage: ((ev: MessageEvent) => void) | null = null
|
|
url: string
|
|
|
|
constructor(url: string) {
|
|
this.url = url
|
|
// Auto-open in next tick
|
|
setTimeout(() => {
|
|
this.readyState = MockWebSocket.OPEN
|
|
this.onopen?.(new Event('open'))
|
|
}, 0)
|
|
}
|
|
|
|
send = vi.fn()
|
|
close = vi.fn().mockImplementation(function (this: MockWebSocket) {
|
|
this.readyState = MockWebSocket.CLOSED
|
|
this.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
|
|
})
|
|
}
|
|
|
|
vi.stubGlobal('WebSocket', MockWebSocket)
|
|
|
|
// Must import after mocks
|
|
const { WebSocketClient, applyDataPatch } = await import('../websocket')
|
|
|
|
describe('WebSocketClient', () => {
|
|
let client: InstanceType<typeof WebSocketClient>
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.clearAllMocks()
|
|
client = new WebSocketClient('/ws/test')
|
|
})
|
|
|
|
afterEach(() => {
|
|
client.reset()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('initializes with disconnected state', () => {
|
|
expect(client.state).toBe('disconnected')
|
|
expect(client.isConnected()).toBe(false)
|
|
})
|
|
|
|
it('connects and transitions to connected state', async () => {
|
|
const states: string[] = []
|
|
client.onConnectionStateChange((s) => states.push(s))
|
|
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
expect(client.state).toBe('connected')
|
|
expect(client.isConnected()).toBe(true)
|
|
expect(states).toContain('connecting')
|
|
expect(states).toContain('connected')
|
|
})
|
|
|
|
it('resolves immediately if already connected', async () => {
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
// Second connect should resolve immediately
|
|
await client.connect()
|
|
expect(client.isConnected()).toBe(true)
|
|
})
|
|
|
|
it('subscribe returns unsubscribe function', async () => {
|
|
const callback = vi.fn()
|
|
const unsub = client.subscribe(callback)
|
|
|
|
expect(typeof unsub).toBe('function')
|
|
unsub()
|
|
// Should not throw
|
|
})
|
|
|
|
it('notifies subscribers on message', async () => {
|
|
const callback = vi.fn()
|
|
client.subscribe(callback)
|
|
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
// Simulate receiving a message
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
|
const update = { id: 1, type: 'state', data: { running: true } }
|
|
ws.onmessage?.(new MessageEvent('message', { data: JSON.stringify(update) }))
|
|
|
|
expect(callback).toHaveBeenCalledWith(update)
|
|
})
|
|
|
|
it('handles malformed JSON messages gracefully', async () => {
|
|
const callback = vi.fn()
|
|
client.subscribe(callback)
|
|
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
|
// Should not throw
|
|
ws.onmessage?.(new MessageEvent('message', { data: 'not-json{' }))
|
|
expect(callback).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('onConnectionStateChange returns unsubscribe function', () => {
|
|
const callback = vi.fn()
|
|
const unsub = client.onConnectionStateChange(callback)
|
|
|
|
expect(typeof unsub).toBe('function')
|
|
unsub()
|
|
})
|
|
|
|
it('disconnect sets state to disconnecting then cleans up', async () => {
|
|
const states: string[] = []
|
|
client.onConnectionStateChange((s) => states.push(s))
|
|
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
client.disconnect()
|
|
|
|
expect(states).toContain('disconnecting')
|
|
expect(client.isConnected()).toBe(false)
|
|
})
|
|
|
|
it('reset clears all callbacks and disconnects', async () => {
|
|
const callback = vi.fn()
|
|
client.subscribe(callback)
|
|
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
client.reset()
|
|
|
|
expect(client.isConnected()).toBe(false)
|
|
})
|
|
|
|
it('sends ping messages via heartbeat', async () => {
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
|
|
|
// Advance past ping interval (30s)
|
|
await vi.advanceTimersByTimeAsync(31000)
|
|
|
|
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'ping' }))
|
|
})
|
|
|
|
it('disconnect prevents reconnection after abnormal close', async () => {
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
// Disconnect explicitly — should prevent future reconnections
|
|
const states: string[] = []
|
|
client.onConnectionStateChange((s) => states.push(s))
|
|
client.disconnect()
|
|
|
|
expect(states).toContain('disconnecting')
|
|
})
|
|
|
|
it('handles close event with normal closure code', async () => {
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
|
|
|
// Simulate normal close — should still try to reconnect (shouldReconnect is true)
|
|
ws.readyState = MockWebSocket.CLOSED
|
|
ws.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
|
|
|
|
// After close, state transitions to disconnected
|
|
// Then reconnection happens automatically (mock auto-opens)
|
|
await vi.advanceTimersByTimeAsync(200)
|
|
|
|
// Client should have attempted reconnect (state went through disconnected → connecting → connected)
|
|
expect(client.state).toBe('connected')
|
|
})
|
|
|
|
it('heartbeat detects stale connection after 5 minutes', async () => {
|
|
const connectPromise = client.connect()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await connectPromise
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws
|
|
const closeSpy = ws.close
|
|
|
|
// Advance 5+ minutes without any messages
|
|
await vi.advanceTimersByTimeAsync(310000)
|
|
|
|
// Heartbeat should have closed the stale connection
|
|
expect(closeSpy).toHaveBeenCalled()
|
|
})
|
|
|
|
it('state getter returns current connection state', () => {
|
|
expect(client.state).toBe('disconnected')
|
|
})
|
|
})
|
|
|
|
describe('applyDataPatch', () => {
|
|
it('returns original data for empty patch', () => {
|
|
const data = { a: 1, b: 2 }
|
|
const result = applyDataPatch(data, [])
|
|
expect(result).toBe(data)
|
|
})
|
|
|
|
it('returns original data for non-array patch', () => {
|
|
const data = { a: 1 }
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const result = applyDataPatch(data, null as any)
|
|
expect(result).toBe(data)
|
|
})
|
|
|
|
it('applies valid patch operations', () => {
|
|
const data = { name: 'test', count: 0 }
|
|
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/count', value: 5 }]
|
|
const result = applyDataPatch(data, patch)
|
|
// The mock returns { ...data, patched: true }
|
|
expect(result).toHaveProperty('patched', true)
|
|
})
|
|
|
|
it('returns original data when patch application throws', async () => {
|
|
// Override mock to throw
|
|
const { applyPatch: mockApplyPatch } = await import('fast-json-patch')
|
|
vi.mocked(mockApplyPatch).mockImplementationOnce(() => {
|
|
throw new Error('Invalid patch')
|
|
})
|
|
|
|
const data = { value: 42 }
|
|
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/invalid', value: 0 }]
|
|
const result = applyDataPatch(data, patch)
|
|
expect(result).toBe(data)
|
|
})
|
|
})
|