688 lines
19 KiB
TypeScript
688 lines
19 KiB
TypeScript
/**
|
|
* WebSocket Composable Tests
|
|
*
|
|
* Tests for Socket.io connection management, authentication, and event handling.
|
|
*/
|
|
|
|
// IMPORTANT: Mock process.env BEFORE any other imports to fix Pinia
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
import { ref } from 'vue'
|
|
import { setActivePinia, createPinia } from 'pinia'
|
|
import { useWebSocket } from '~/composables/useWebSocket'
|
|
|
|
;(globalThis as any).process = {
|
|
...((globalThis as any).process || {}),
|
|
env: {
|
|
...((globalThis as any).process?.env || {}),
|
|
NODE_ENV: 'test',
|
|
},
|
|
client: true,
|
|
}
|
|
|
|
// Mock Socket.io
|
|
const mockSocketInstance = {
|
|
on: vi.fn(),
|
|
emit: vi.fn(),
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
connected: false,
|
|
auth: {},
|
|
}
|
|
|
|
vi.mock('socket.io-client', () => ({
|
|
io: vi.fn(() => mockSocketInstance),
|
|
}))
|
|
|
|
// Mock Nuxt runtime config
|
|
vi.mock('#app', () => ({
|
|
useRuntimeConfig: vi.fn(() => ({
|
|
public: {
|
|
wsUrl: 'http://localhost:8000',
|
|
},
|
|
})),
|
|
}))
|
|
|
|
// Mock stores
|
|
const mockAuthStore = {
|
|
isAuthenticated: ref(true),
|
|
isTokenValid: ref(true),
|
|
token: 'test-jwt-token',
|
|
}
|
|
|
|
const mockGameStore = {
|
|
setConnected: vi.fn(),
|
|
setGameState: vi.fn(),
|
|
addPlayToHistory: vi.fn(),
|
|
setDecisionPrompt: vi.fn(),
|
|
clearDecisionPrompt: vi.fn(),
|
|
setPendingRoll: vi.fn(),
|
|
clearPendingRoll: vi.fn(),
|
|
updateLineup: vi.fn(),
|
|
setError: vi.fn(),
|
|
gameId: 'game-123',
|
|
}
|
|
|
|
const mockUiStore = {
|
|
showSuccess: vi.fn(),
|
|
showError: vi.fn(),
|
|
showWarning: vi.fn(),
|
|
showInfo: vi.fn(),
|
|
}
|
|
|
|
vi.mock('~/store/auth', () => ({
|
|
useAuthStore: vi.fn(() => mockAuthStore),
|
|
}))
|
|
|
|
vi.mock('~/store/game', () => ({
|
|
useGameStore: vi.fn(() => mockGameStore),
|
|
}))
|
|
|
|
vi.mock('~/store/ui', () => ({
|
|
useUiStore: vi.fn(() => mockUiStore),
|
|
}))
|
|
|
|
// TODO: Fix import.meta.client issues for WebSocket operations in tests
|
|
describe.skip('useWebSocket', () => {
|
|
beforeEach(() => {
|
|
// Ensure process.env exists before creating Pinia
|
|
if (!process.env) {
|
|
(process as any).env = {}
|
|
}
|
|
process.env.NODE_ENV = 'test'
|
|
|
|
setActivePinia(createPinia())
|
|
|
|
// Reset all mocks
|
|
vi.clearAllMocks()
|
|
|
|
// Reset mock socket state
|
|
mockSocketInstance.connected = false
|
|
mockSocketInstance.on.mockClear()
|
|
mockSocketInstance.emit.mockClear()
|
|
mockSocketInstance.connect.mockClear()
|
|
mockSocketInstance.disconnect.mockClear()
|
|
|
|
// Reset auth store state
|
|
mockAuthStore.isAuthenticated.value = true
|
|
mockAuthStore.isTokenValid.value = true
|
|
|
|
// Use fake timers for testing timeouts
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('initialization', () => {
|
|
it('initializes with disconnected state', () => {
|
|
const ws = useWebSocket()
|
|
|
|
expect(ws.isConnected.value).toBe(false)
|
|
expect(ws.isConnecting.value).toBe(false)
|
|
expect(ws.connectionError.value).toBeNull()
|
|
expect(ws.socket.value).toBeNull()
|
|
})
|
|
|
|
it('computes canConnect based on auth state', () => {
|
|
const { useAuthStore } = require('~/store/auth')
|
|
|
|
vi.mocked(useAuthStore).mockReturnValue({
|
|
...mockAuthStore,
|
|
isAuthenticated: ref(true),
|
|
isTokenValid: ref(true),
|
|
})
|
|
|
|
const ws = useWebSocket()
|
|
|
|
// canConnect is a ComputedRef, so use .value
|
|
expect(ws.canConnect.value).toBe(true)
|
|
})
|
|
|
|
it('cannot connect when not authenticated', () => {
|
|
const { useAuthStore } = require('~/store/auth')
|
|
|
|
vi.mocked(useAuthStore).mockReturnValue({
|
|
...mockAuthStore,
|
|
isAuthenticated: ref(false),
|
|
isTokenValid: ref(true),
|
|
})
|
|
|
|
const ws = useWebSocket()
|
|
|
|
expect(ws.canConnect.value).toBe(false)
|
|
})
|
|
|
|
it('cannot connect when token is invalid', () => {
|
|
const { useAuthStore } = require('~/store/auth')
|
|
|
|
vi.mocked(useAuthStore).mockReturnValue({
|
|
...mockAuthStore,
|
|
isAuthenticated: ref(true),
|
|
isTokenValid: ref(false),
|
|
})
|
|
|
|
const ws = useWebSocket()
|
|
|
|
expect(ws.canConnect.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('connection lifecycle', () => {
|
|
it('connects with JWT authentication', () => {
|
|
const { io } = require('socket.io-client')
|
|
const ws = useWebSocket()
|
|
|
|
ws.connect()
|
|
|
|
expect(io).toHaveBeenCalledWith('http://localhost:8000', {
|
|
auth: {
|
|
token: 'test-jwt-token',
|
|
},
|
|
autoConnect: false,
|
|
reconnection: false,
|
|
transports: ['websocket', 'polling'],
|
|
})
|
|
expect(mockSocketInstance.connect).toHaveBeenCalled()
|
|
expect(ws.isConnecting.value).toBe(true)
|
|
})
|
|
|
|
it('does not connect when not authenticated', () => {
|
|
mockAuthStore.isAuthenticated.value = false
|
|
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
expect(mockSocketInstance.connect).not.toHaveBeenCalled()
|
|
expect(ws.isConnecting.value).toBe(false)
|
|
})
|
|
|
|
it('does not connect when token is invalid', () => {
|
|
mockAuthStore.isTokenValid.value = false
|
|
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
expect(mockSocketInstance.connect).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not connect when already connected', () => {
|
|
const ws = useWebSocket()
|
|
|
|
ws.connect()
|
|
mockSocketInstance.connect.mockClear()
|
|
|
|
// Simulate connected state
|
|
ws.isConnected.value = true
|
|
|
|
ws.connect()
|
|
|
|
expect(mockSocketInstance.connect).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('updates state on successful connection', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
// Simulate 'connect' event
|
|
const connectHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect'
|
|
)?.[1]
|
|
expect(connectHandler).toBeDefined()
|
|
|
|
connectHandler?.()
|
|
|
|
expect(ws.isConnected.value).toBe(true)
|
|
expect(ws.isConnecting.value).toBe(false)
|
|
expect(ws.connectionError.value).toBeNull()
|
|
expect(mockGameStore.setConnected).toHaveBeenCalledWith(true)
|
|
expect(mockUiStore.showSuccess).toHaveBeenCalledWith('Connected to game server')
|
|
})
|
|
|
|
it('disconnects and clears state', () => {
|
|
const ws = useWebSocket()
|
|
|
|
ws.connect()
|
|
|
|
// Simulate connected
|
|
const connectHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect'
|
|
)?.[1]
|
|
connectHandler?.()
|
|
|
|
expect(ws.isConnected.value).toBe(true)
|
|
|
|
// Disconnect
|
|
ws.disconnect()
|
|
|
|
expect(mockSocketInstance.disconnect).toHaveBeenCalled()
|
|
expect(ws.isConnected.value).toBe(false)
|
|
expect(ws.isConnecting.value).toBe(false)
|
|
})
|
|
|
|
it('handles disconnection event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
// Simulate 'disconnect' event
|
|
const disconnectHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'disconnect'
|
|
)?.[1]
|
|
expect(disconnectHandler).toBeDefined()
|
|
|
|
disconnectHandler?.('transport close')
|
|
|
|
expect(ws.isConnected.value).toBe(false)
|
|
expect(mockGameStore.setConnected).toHaveBeenCalledWith(false)
|
|
expect(mockUiStore.showWarning).toHaveBeenCalledWith('Disconnected from game server')
|
|
})
|
|
|
|
it('handles connection error', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
// Simulate 'connect_error' event
|
|
const errorHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect_error'
|
|
)?.[1]
|
|
expect(errorHandler).toBeDefined()
|
|
|
|
const error = new Error('Connection failed')
|
|
errorHandler?.(error)
|
|
|
|
expect(ws.isConnected.value).toBe(false)
|
|
expect(ws.isConnecting.value).toBe(false)
|
|
expect(ws.connectionError.value).toBe('Connection failed')
|
|
expect(mockGameStore.setError).toHaveBeenCalledWith('Connection failed')
|
|
expect(mockUiStore.showError).toHaveBeenCalledWith('Connection failed: Connection failed')
|
|
})
|
|
})
|
|
|
|
describe('exponential backoff reconnection', () => {
|
|
it('calculates exponential backoff delay correctly', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
// Get disconnect handler
|
|
const disconnectHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'disconnect'
|
|
)?.[1]
|
|
|
|
// Trigger disconnect (not intentional)
|
|
disconnectHandler?.('transport close')
|
|
|
|
// Should schedule reconnection
|
|
expect(setTimeout).toHaveBeenCalled()
|
|
|
|
// First attempt should be 1000ms (2^0 * 1000)
|
|
const firstCall = vi.mocked(setTimeout).mock.calls[0]
|
|
expect(firstCall[1]).toBe(1000)
|
|
})
|
|
|
|
it('increases delay with each failed attempt', () => {
|
|
const ws = useWebSocket()
|
|
|
|
// Simulate multiple connection failures
|
|
for (let i = 0; i < 3; i++) {
|
|
ws.connect()
|
|
|
|
const errorHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect_error'
|
|
)?.[1]
|
|
|
|
errorHandler?.(new Error('Connection failed'))
|
|
|
|
vi.advanceTimersByTime(100000) // Fast-forward timers
|
|
}
|
|
|
|
// Check that delays increase exponentially
|
|
const timeoutCalls = vi.mocked(setTimeout).mock.calls
|
|
const delays = timeoutCalls.map((call) => call[1]).filter((delay) => delay !== 30000) // Filter out heartbeat
|
|
|
|
// First 3 attempts: 1000ms, 2000ms, 4000ms
|
|
expect(delays).toContain(1000)
|
|
expect(delays).toContain(2000)
|
|
expect(delays).toContain(4000)
|
|
})
|
|
|
|
it('caps reconnection delay at maximum', () => {
|
|
const ws = useWebSocket()
|
|
|
|
// Simulate many connection failures to exceed max delay
|
|
for (let i = 0; i < 10; i++) {
|
|
ws.connect()
|
|
|
|
const errorHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect_error'
|
|
)?.[1]
|
|
|
|
errorHandler?.(new Error('Connection failed'))
|
|
|
|
vi.advanceTimersByTime(100000)
|
|
}
|
|
|
|
// Check that delay never exceeds 30000ms
|
|
const timeoutCalls = vi.mocked(setTimeout).mock.calls
|
|
const delays = timeoutCalls.map((call) => call[1])
|
|
|
|
// All delays should be <= 30000ms
|
|
delays.forEach((delay) => {
|
|
expect(delay).toBeLessThanOrEqual(30000)
|
|
})
|
|
})
|
|
|
|
it('does not reconnect on intentional disconnect', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const disconnectHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'disconnect'
|
|
)?.[1]
|
|
|
|
vi.clearAllTimers()
|
|
const timeoutsBefore = vi.getTimerCount()
|
|
|
|
// Trigger intentional disconnect
|
|
disconnectHandler?.('io client disconnect')
|
|
|
|
const timeoutsAfter = vi.getTimerCount()
|
|
|
|
// Should not schedule reconnection
|
|
expect(timeoutsAfter).toBe(timeoutsBefore)
|
|
})
|
|
|
|
it('stops reconnecting after max attempts', () => {
|
|
const ws = useWebSocket()
|
|
|
|
// Simulate 10 failed attempts (max is 10)
|
|
for (let i = 0; i < 11; i++) {
|
|
ws.connect()
|
|
|
|
const errorHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect_error'
|
|
)?.[1]
|
|
|
|
errorHandler?.(new Error('Connection failed'))
|
|
|
|
vi.advanceTimersByTime(100000)
|
|
}
|
|
|
|
// After 10 attempts, should not schedule more reconnections
|
|
// This is tested implicitly - the 11th attempt should not schedule a timeout
|
|
})
|
|
|
|
it('resets reconnection attempts on successful connection', () => {
|
|
const ws = useWebSocket()
|
|
|
|
// Fail once
|
|
ws.connect()
|
|
const errorHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect_error'
|
|
)?.[1]
|
|
errorHandler?.(new Error('Connection failed'))
|
|
|
|
// Then succeed
|
|
const connectHandler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'connect'
|
|
)?.[1]
|
|
connectHandler?.()
|
|
|
|
expect(ws.isConnected.value).toBe(true)
|
|
|
|
// Reconnection attempts should be reset to 0
|
|
// (tested implicitly through state)
|
|
})
|
|
})
|
|
|
|
describe('event handler registration', () => {
|
|
it('registers connection event handlers', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event)
|
|
|
|
expect(registeredEvents).toContain('connect')
|
|
expect(registeredEvents).toContain('disconnect')
|
|
expect(registeredEvents).toContain('connect_error')
|
|
expect(registeredEvents).toContain('connected')
|
|
expect(registeredEvents).toContain('heartbeat_ack')
|
|
})
|
|
|
|
it('registers game state event handlers', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event)
|
|
|
|
expect(registeredEvents).toContain('game_state_update')
|
|
expect(registeredEvents).toContain('game_state_sync')
|
|
expect(registeredEvents).toContain('play_completed')
|
|
expect(registeredEvents).toContain('inning_change')
|
|
expect(registeredEvents).toContain('game_ended')
|
|
})
|
|
|
|
it('registers decision event handlers', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event)
|
|
|
|
expect(registeredEvents).toContain('decision_required')
|
|
expect(registeredEvents).toContain('defensive_decision_submitted')
|
|
expect(registeredEvents).toContain('offensive_decision_submitted')
|
|
})
|
|
|
|
it('registers manual workflow event handlers', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event)
|
|
|
|
expect(registeredEvents).toContain('dice_rolled')
|
|
expect(registeredEvents).toContain('outcome_accepted')
|
|
})
|
|
|
|
it('registers error event handlers', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const registeredEvents = mockSocketInstance.on.mock.calls.map(([event]) => event)
|
|
|
|
expect(registeredEvents).toContain('error')
|
|
expect(registeredEvents).toContain('outcome_rejected')
|
|
expect(registeredEvents).toContain('substitution_error')
|
|
expect(registeredEvents).toContain('invalid_action')
|
|
expect(registeredEvents).toContain('connection_error')
|
|
})
|
|
})
|
|
|
|
describe('game state event handling', () => {
|
|
it('handles game_state_update event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'game_state_update'
|
|
)?.[1]
|
|
|
|
const mockState = { game_id: 'game-123', inning: 5 }
|
|
handler?.(mockState)
|
|
|
|
expect(mockGameStore.setGameState).toHaveBeenCalledWith(mockState)
|
|
})
|
|
|
|
it('handles game_state_sync event with recent plays', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'game_state_sync'
|
|
)?.[1]
|
|
|
|
const mockData = {
|
|
state: { game_id: 'game-123', inning: 3 },
|
|
recent_plays: [
|
|
{ play_number: 1, outcome: 'SINGLE_1', description: 'Single' },
|
|
{ play_number: 2, outcome: 'OUT', description: 'Out' },
|
|
],
|
|
}
|
|
|
|
handler?.(mockData)
|
|
|
|
expect(mockGameStore.setGameState).toHaveBeenCalledWith(mockData.state)
|
|
expect(mockGameStore.addPlayToHistory).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('handles play_completed event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'play_completed'
|
|
)?.[1]
|
|
|
|
const mockPlay = {
|
|
play_number: 1,
|
|
outcome: 'SINGLE_1',
|
|
description: 'Single to center',
|
|
}
|
|
|
|
handler?.(mockPlay)
|
|
|
|
expect(mockGameStore.addPlayToHistory).toHaveBeenCalledWith(mockPlay)
|
|
expect(mockUiStore.showInfo).toHaveBeenCalledWith('Single to center', 3000)
|
|
})
|
|
|
|
it('handles decision_required event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'decision_required'
|
|
)?.[1]
|
|
|
|
const mockPrompt = {
|
|
phase: 'defense',
|
|
role: 'home',
|
|
timeout_seconds: 30,
|
|
}
|
|
|
|
handler?.(mockPrompt)
|
|
|
|
expect(mockGameStore.setDecisionPrompt).toHaveBeenCalledWith(mockPrompt)
|
|
})
|
|
|
|
it('handles dice_rolled event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'dice_rolled'
|
|
)?.[1]
|
|
|
|
const mockRollData = {
|
|
roll_id: 'roll-123',
|
|
d6_one: 4,
|
|
d6_two_total: 7,
|
|
chaos_d20: 15,
|
|
resolution_d20: 12,
|
|
check_wild_pitch: false,
|
|
check_passed_ball: false,
|
|
timestamp: '2025-01-10T12:00:00Z',
|
|
message: 'Dice rolled!',
|
|
}
|
|
|
|
handler?.(mockRollData)
|
|
|
|
expect(mockGameStore.setPendingRoll).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
roll_id: 'roll-123',
|
|
d6_one: 4,
|
|
d6_two_total: 7,
|
|
chaos_d20: 15,
|
|
resolution_d20: 12,
|
|
})
|
|
)
|
|
expect(mockUiStore.showInfo).toHaveBeenCalledWith('Dice rolled!', 5000)
|
|
})
|
|
})
|
|
|
|
describe('error event handling', () => {
|
|
it('handles server error event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(([event]) => event === 'error')?.[1]
|
|
|
|
handler?.({ message: 'Server error occurred' })
|
|
|
|
expect(mockGameStore.setError).toHaveBeenCalledWith('Server error occurred')
|
|
expect(mockUiStore.showError).toHaveBeenCalledWith('Server error occurred', 7000)
|
|
})
|
|
|
|
it('handles outcome_rejected event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'outcome_rejected'
|
|
)?.[1]
|
|
|
|
handler?.({ message: 'Invalid outcome' })
|
|
|
|
expect(mockUiStore.showError).toHaveBeenCalledWith('Invalid outcome: Invalid outcome', 7000)
|
|
})
|
|
|
|
it('handles invalid_action event', () => {
|
|
const ws = useWebSocket()
|
|
ws.connect()
|
|
|
|
const handler = mockSocketInstance.on.mock.calls.find(
|
|
([event]) => event === 'invalid_action'
|
|
)?.[1]
|
|
|
|
handler?.({ reason: 'Not your turn' })
|
|
|
|
expect(mockUiStore.showError).toHaveBeenCalledWith('Invalid action: Not your turn', 7000)
|
|
})
|
|
})
|
|
|
|
describe('JWT token update on reconnection', () => {
|
|
it('updates auth token when reconnecting', () => {
|
|
const { io } = require('socket.io-client')
|
|
const ws = useWebSocket()
|
|
|
|
// First connection
|
|
ws.connect()
|
|
|
|
// Change token
|
|
mockAuthStore.token = 'new-jwt-token'
|
|
|
|
// Reconnect (socket instance exists)
|
|
ws.connect()
|
|
|
|
// Should update auth token
|
|
expect(mockSocketInstance.auth).toEqual({
|
|
token: 'new-jwt-token',
|
|
})
|
|
})
|
|
|
|
it('creates new socket with auth on first connection', () => {
|
|
const { io } = require('socket.io-client')
|
|
const ws = useWebSocket()
|
|
|
|
ws.connect()
|
|
|
|
expect(io).toHaveBeenCalledWith(
|
|
'http://localhost:8000',
|
|
expect.objectContaining({
|
|
auth: {
|
|
token: 'test-jwt-token',
|
|
},
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|