test: add unit tests for vision broadcast feature

- VisionIndicator.test.tsx: tests for rendering, status indicator, click behavior, accessibility
- ObserverVisionFeed.test.tsx: tests for image display, detected value, live/stale indicator
- useSessionBroadcast.vision.test.ts: tests for sendVisionFrame socket emission
- useSessionObserver.vision.test.ts: tests for visionFrame receiving and cleanup
- MyAbacusContext.vision.test.tsx: tests for vision config state and callbacks

Also fixes:
- useSessionObserver: clear visionFrame and transitionState on stopObserving
- test/setup.ts: add canvas Image mock to prevent jsdom errors with data URIs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2026-01-01 16:08:51 -06:00
parent a5025f01bc
commit 43524d8238
7 changed files with 1288 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
/**
* Unit tests for ObserverVisionFeed component
*
* Note: Canvas.Image mock is provided in src/test/setup.ts to prevent
* jsdom errors with data URI images. Actual image rendering is verified
* through integration/e2e tests.
*/
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ObservedVisionFrame } from '@/hooks/useSessionObserver'
import { ObserverVisionFeed } from '../ObserverVisionFeed'
describe('ObserverVisionFeed', () => {
const createMockFrame = (overrides?: Partial<ObservedVisionFrame>): ObservedVisionFrame => ({
imageData: 'base64ImageData==',
detectedValue: 123,
confidence: 0.95,
receivedAt: Date.now(),
...overrides,
})
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('rendering', () => {
it('renders the vision feed container', () => {
const frame = createMockFrame()
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByRole('img')).toBeInTheDocument()
})
it('displays the image with correct src', () => {
const frame = createMockFrame({ imageData: 'testImageData123' })
render(<ObserverVisionFeed frame={frame} />)
const img = screen.getByRole('img') as HTMLImageElement
// Check the src property (not attribute) because our test setup
// intercepts data:image/ src attributes to prevent jsdom canvas errors
expect(img.src).toBe('data:image/jpeg;base64,testImageData123')
})
it('has appropriate alt text for accessibility', () => {
const frame = createMockFrame()
render(<ObserverVisionFeed frame={frame} />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('alt', "Student's abacus vision feed")
})
})
describe('detected value display', () => {
it('displays the detected value', () => {
const frame = createMockFrame({ detectedValue: 456, confidence: 0.87 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('456')).toBeInTheDocument()
})
it('displays confidence percentage', () => {
const frame = createMockFrame({ detectedValue: 123, confidence: 0.87 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('87%')).toBeInTheDocument()
})
it('displays dashes when detectedValue is null', () => {
const frame = createMockFrame({ detectedValue: null, confidence: 0 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('---')).toBeInTheDocument()
})
it('hides confidence when value is null', () => {
const frame = createMockFrame({ detectedValue: null, confidence: 0.95 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.queryByText('95%')).not.toBeInTheDocument()
})
it('handles zero as a valid detected value', () => {
const frame = createMockFrame({ detectedValue: 0, confidence: 0.99 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('99%')).toBeInTheDocument()
})
})
describe('live/stale indicator', () => {
it('shows Live status for fresh frames (less than 1 second old)', () => {
const now = Date.now()
vi.setSystemTime(now)
const frame = createMockFrame({ receivedAt: now - 500 }) // 500ms ago
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('Live')).toBeInTheDocument()
})
it('shows Stale status for old frames (more than 1 second old)', () => {
const now = Date.now()
vi.setSystemTime(now)
const frame = createMockFrame({ receivedAt: now - 1500 }) // 1.5 seconds ago
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('Stale')).toBeInTheDocument()
})
it('sets stale data attribute when frame is old', () => {
const now = Date.now()
vi.setSystemTime(now)
const frame = createMockFrame({ receivedAt: now - 2000 }) // 2 seconds ago
const { container } = render(<ObserverVisionFeed frame={frame} />)
const component = container.querySelector('[data-component="observer-vision-feed"]')
expect(component).toHaveAttribute('data-stale', 'true')
})
it('sets stale data attribute to false for fresh frames', () => {
const now = Date.now()
vi.setSystemTime(now)
const frame = createMockFrame({ receivedAt: now - 100 }) // 100ms ago
const { container } = render(<ObserverVisionFeed frame={frame} />)
const component = container.querySelector('[data-component="observer-vision-feed"]')
expect(component).toHaveAttribute('data-stale', 'false')
})
it('reduces image opacity for stale frames', () => {
const now = Date.now()
vi.setSystemTime(now)
const frame = createMockFrame({ receivedAt: now - 2000 })
render(<ObserverVisionFeed frame={frame} />)
const img = screen.getByRole('img')
// The opacity should be reduced for stale frames
expect(img.className).toBeDefined()
})
})
describe('vision badge', () => {
it('displays the vision badge', () => {
const frame = createMockFrame()
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('📷')).toBeInTheDocument()
expect(screen.getByText('Vision')).toBeInTheDocument()
})
})
describe('edge cases', () => {
it('handles very large detected values', () => {
const frame = createMockFrame({ detectedValue: 99999, confidence: 1.0 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('99999')).toBeInTheDocument()
expect(screen.getByText('100%')).toBeInTheDocument()
})
it('rounds confidence to nearest integer', () => {
const frame = createMockFrame({ detectedValue: 123, confidence: 0.876 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('88%')).toBeInTheDocument()
})
it('handles confidence edge case of exactly 1', () => {
const frame = createMockFrame({ detectedValue: 123, confidence: 1.0 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('100%')).toBeInTheDocument()
})
it('handles confidence edge case of exactly 0', () => {
const frame = createMockFrame({ detectedValue: 123, confidence: 0 })
render(<ObserverVisionFeed frame={frame} />)
expect(screen.getByText('0%')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,173 @@
/**
* Unit tests for VisionIndicator component
*/
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { VisionIndicator } from '../VisionIndicator'
// Mock the MyAbacusContext
const mockOpenVisionSetup = vi.fn()
const mockVisionConfig = {
enabled: false,
cameraDeviceId: null,
calibration: null,
remoteCameraSessionId: null,
}
vi.mock('@/contexts/MyAbacusContext', () => ({
useMyAbacus: () => ({
visionConfig: mockVisionConfig,
isVisionSetupComplete:
mockVisionConfig.cameraDeviceId !== null && mockVisionConfig.calibration !== null,
openVisionSetup: mockOpenVisionSetup,
}),
}))
describe('VisionIndicator', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to default state
mockVisionConfig.enabled = false
mockVisionConfig.cameraDeviceId = null
mockVisionConfig.calibration = null
mockVisionConfig.remoteCameraSessionId = null
})
describe('rendering', () => {
it('renders the camera icon', () => {
render(<VisionIndicator />)
expect(screen.getByText('📷')).toBeInTheDocument()
})
it('renders with medium size by default', () => {
render(<VisionIndicator />)
const button = screen.getByRole('button')
// Medium size button should exist with the vision-status attribute
expect(button).toHaveAttribute('data-vision-status')
})
it('renders with small size when specified', () => {
render(<VisionIndicator size="small" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('status indicator', () => {
it('shows not-configured status when camera is not set', () => {
mockVisionConfig.cameraDeviceId = null
mockVisionConfig.calibration = null
render(<VisionIndicator />)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('data-vision-status', 'not-configured')
})
it('shows disabled status when configured but not enabled', () => {
mockVisionConfig.cameraDeviceId = 'camera-123'
mockVisionConfig.calibration = {
roi: { x: 0, y: 0, width: 100, height: 100 },
columnCount: 5,
columnDividers: [],
rotation: 0,
}
mockVisionConfig.enabled = false
render(<VisionIndicator />)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('data-vision-status', 'disabled')
})
it('shows enabled status when configured and enabled', () => {
mockVisionConfig.cameraDeviceId = 'camera-123'
mockVisionConfig.calibration = {
roi: { x: 0, y: 0, width: 100, height: 100 },
columnCount: 5,
columnDividers: [],
rotation: 0,
}
mockVisionConfig.enabled = true
render(<VisionIndicator />)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('data-vision-status', 'enabled')
})
})
describe('click behavior', () => {
it('opens setup modal on click', () => {
render(<VisionIndicator />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockOpenVisionSetup).toHaveBeenCalledTimes(1)
})
it('opens setup modal on right-click', () => {
render(<VisionIndicator />)
const button = screen.getByRole('button')
fireEvent.contextMenu(button)
expect(mockOpenVisionSetup).toHaveBeenCalledTimes(1)
})
it('stops event propagation on click', () => {
const parentClickHandler = vi.fn()
render(
<div onClick={parentClickHandler}>
<VisionIndicator />
</div>
)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(parentClickHandler).not.toHaveBeenCalled()
})
})
describe('accessibility', () => {
it('has appropriate title based on status', () => {
mockVisionConfig.cameraDeviceId = null
render(<VisionIndicator />)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('title', expect.stringContaining('not configured'))
})
it('updates title when vision is enabled', () => {
mockVisionConfig.cameraDeviceId = 'camera-123'
mockVisionConfig.calibration = {
roi: { x: 0, y: 0, width: 100, height: 100 },
columnCount: 5,
columnDividers: [],
rotation: 0,
}
mockVisionConfig.enabled = true
render(<VisionIndicator />)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('title', expect.stringContaining('enabled'))
})
})
describe('positioning', () => {
it('uses bottom-right position by default', () => {
render(<VisionIndicator />)
const button = screen.getByRole('button')
expect(button.style.position).toBe('absolute')
})
it('accepts top-left position', () => {
render(<VisionIndicator position="top-left" />)
const button = screen.getByRole('button')
expect(button.style.position).toBe('absolute')
})
})
})

View File

@@ -0,0 +1,432 @@
/**
* Unit tests for MyAbacusContext vision functionality
*/
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MyAbacusProvider, useMyAbacus, type VisionFrameData } from '../MyAbacusContext'
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
}
})()
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
describe('MyAbacusContext - vision functionality', () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MyAbacusProvider>{children}</MyAbacusProvider>
)
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
})
describe('visionConfig state', () => {
it('starts with vision disabled', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
expect(result.current.visionConfig.enabled).toBe(false)
})
it('starts with null cameraDeviceId', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
expect(result.current.visionConfig.cameraDeviceId).toBeNull()
})
it('starts with null calibration', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
expect(result.current.visionConfig.calibration).toBeNull()
})
it('starts with null remoteCameraSessionId', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
expect(result.current.visionConfig.remoteCameraSessionId).toBeNull()
})
})
describe('isVisionSetupComplete', () => {
it('returns false when camera is not set', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
expect(result.current.isVisionSetupComplete).toBe(false)
})
it('returns false when calibration is not set', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionCamera('camera-123')
})
expect(result.current.isVisionSetupComplete).toBe(false)
})
it('returns true when both camera and calibration are set', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionCamera('camera-123')
result.current.setVisionCalibration({
roi: { x: 0, y: 0, width: 100, height: 100 },
columnCount: 5,
columnDividers: [],
rotation: 0,
})
})
expect(result.current.isVisionSetupComplete).toBe(true)
})
})
describe('setVisionEnabled', () => {
it('enables vision mode', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionEnabled(true)
})
expect(result.current.visionConfig.enabled).toBe(true)
})
it('disables vision mode', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionEnabled(true)
})
act(() => {
result.current.setVisionEnabled(false)
})
expect(result.current.visionConfig.enabled).toBe(false)
})
it('persists to localStorage', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionEnabled(true)
})
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'abacus-vision-config',
expect.stringContaining('"enabled":true')
)
})
})
describe('setVisionCamera', () => {
it('sets camera device ID', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionCamera('camera-device-123')
})
expect(result.current.visionConfig.cameraDeviceId).toBe('camera-device-123')
})
it('clears camera device ID when set to null', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionCamera('camera-123')
})
act(() => {
result.current.setVisionCamera(null)
})
expect(result.current.visionConfig.cameraDeviceId).toBeNull()
})
it('persists to localStorage', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionCamera('camera-abc')
})
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'abacus-vision-config',
expect.stringContaining('"cameraDeviceId":"camera-abc"')
)
})
})
describe('setVisionCalibration', () => {
it('sets calibration grid', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
const calibration = {
roi: { x: 10, y: 20, width: 200, height: 100 },
columnCount: 5,
columnDividers: [0.2, 0.4, 0.6, 0.8],
rotation: 0,
}
act(() => {
result.current.setVisionCalibration(calibration)
})
expect(result.current.visionConfig.calibration).toEqual(calibration)
})
it('clears calibration when set to null', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionCalibration({
roi: { x: 0, y: 0, width: 100, height: 100 },
columnCount: 5,
columnDividers: [],
rotation: 0,
})
})
act(() => {
result.current.setVisionCalibration(null)
})
expect(result.current.visionConfig.calibration).toBeNull()
})
})
describe('setVisionRemoteSession', () => {
it('sets remote camera session ID', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionRemoteSession('remote-session-456')
})
expect(result.current.visionConfig.remoteCameraSessionId).toBe('remote-session-456')
})
it('clears remote session when set to null', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.setVisionRemoteSession('session-123')
})
act(() => {
result.current.setVisionRemoteSession(null)
})
expect(result.current.visionConfig.remoteCameraSessionId).toBeNull()
})
})
describe('vision setup modal', () => {
it('starts with modal closed', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
expect(result.current.isVisionSetupOpen).toBe(false)
})
it('opens the setup modal', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.openVisionSetup()
})
expect(result.current.isVisionSetupOpen).toBe(true)
})
it('closes the setup modal', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
act(() => {
result.current.openVisionSetup()
})
act(() => {
result.current.closeVisionSetup()
})
expect(result.current.isVisionSetupOpen).toBe(false)
})
})
describe('vision frame callback', () => {
it('setVisionFrameCallback sets the callback', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
const callback = vi.fn()
act(() => {
result.current.setVisionFrameCallback(callback)
})
// The callback should be stored (we can verify by emitting a frame)
const frame: VisionFrameData = {
imageData: 'test',
detectedValue: 123,
confidence: 0.9,
}
act(() => {
result.current.emitVisionFrame(frame)
})
expect(callback).toHaveBeenCalledWith(frame)
})
it('emitVisionFrame calls the registered callback', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
const callback = vi.fn()
act(() => {
result.current.setVisionFrameCallback(callback)
})
const frame: VisionFrameData = {
imageData: 'base64data',
detectedValue: 456,
confidence: 0.85,
}
act(() => {
result.current.emitVisionFrame(frame)
})
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(frame)
})
it('emitVisionFrame does nothing when no callback is set', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
// This should not throw
const frame: VisionFrameData = {
imageData: 'test',
detectedValue: 123,
confidence: 0.9,
}
expect(() => {
act(() => {
result.current.emitVisionFrame(frame)
})
}).not.toThrow()
})
it('clearing callback stops emissions', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
const callback = vi.fn()
act(() => {
result.current.setVisionFrameCallback(callback)
})
act(() => {
result.current.setVisionFrameCallback(null)
})
const frame: VisionFrameData = {
imageData: 'test',
detectedValue: 123,
confidence: 0.9,
}
act(() => {
result.current.emitVisionFrame(frame)
})
expect(callback).not.toHaveBeenCalled()
})
it('handles null detectedValue in frame', () => {
const { result } = renderHook(() => useMyAbacus(), { wrapper })
const callback = vi.fn()
act(() => {
result.current.setVisionFrameCallback(callback)
})
const frame: VisionFrameData = {
imageData: 'test',
detectedValue: null,
confidence: 0,
}
act(() => {
result.current.emitVisionFrame(frame)
})
expect(callback).toHaveBeenCalledWith({
imageData: 'test',
detectedValue: null,
confidence: 0,
})
})
})
describe('localStorage persistence', () => {
it('loads saved config from localStorage on mount', () => {
const savedConfig = {
enabled: false, // Always starts disabled per the code logic
cameraDeviceId: 'saved-camera',
calibration: {
roi: { x: 0, y: 0, width: 100, height: 100 },
columnCount: 5,
columnDividers: [],
rotation: 0,
},
remoteCameraSessionId: 'saved-session',
}
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(savedConfig))
const { result } = renderHook(() => useMyAbacus(), { wrapper })
// Wait for effect to run
expect(result.current.visionConfig.cameraDeviceId).toBe('saved-camera')
// Note: enabled is always false on load per the implementation
expect(result.current.visionConfig.enabled).toBe(false)
})
it('handles corrupted localStorage gracefully', () => {
localStorageMock.getItem.mockReturnValueOnce('invalid json {{{')
// Should not throw
const { result } = renderHook(() => useMyAbacus(), { wrapper })
expect(result.current.visionConfig).toBeDefined()
expect(result.current.visionConfig.enabled).toBe(false)
})
})
describe('negative cases', () => {
it('throws when useMyAbacus is used outside provider', () => {
// Using renderHook without the wrapper should throw
expect(() => {
renderHook(() => useMyAbacus())
}).toThrow('useMyAbacus must be used within MyAbacusProvider')
})
})
})

View File

@@ -0,0 +1,218 @@
/**
* Unit tests for useSessionBroadcast vision frame broadcasting
*/
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BroadcastState } from '@/components/practice'
import { useSessionBroadcast } from '../useSessionBroadcast'
// Mock socket.io-client
const mockSocket = {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
connected: true,
}
vi.mock('socket.io-client', () => ({
io: vi.fn(() => mockSocket),
}))
describe('useSessionBroadcast - vision frame broadcasting', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSocket.on.mockReset()
mockSocket.emit.mockReset()
})
const createMockBroadcastState = (): BroadcastState => ({
currentProblem: { terms: [5, 3], answer: 8 },
phase: 'problem',
studentAnswer: '',
isCorrect: null,
startedAt: Date.now(),
purpose: 'focus',
complexity: undefined,
currentProblemNumber: 1,
totalProblems: 10,
sessionParts: [],
currentPartIndex: 0,
currentSlotIndex: 0,
slotResults: [],
})
describe('sendVisionFrame', () => {
it('returns sendVisionFrame function', () => {
const { result } = renderHook(() =>
useSessionBroadcast('session-123', 'player-456', createMockBroadcastState())
)
expect(result.current.sendVisionFrame).toBeDefined()
expect(typeof result.current.sendVisionFrame).toBe('function')
})
it('emits vision-frame event with correct payload when connected', async () => {
// Simulate connection
let connectHandler: (() => void) | undefined
mockSocket.on.mockImplementation((event: string, handler: unknown) => {
if (event === 'connect') {
connectHandler = handler as () => void
}
return mockSocket
})
const { result } = renderHook(() =>
useSessionBroadcast('session-123', 'player-456', createMockBroadcastState())
)
// Trigger connect
act(() => {
connectHandler?.()
})
// Send vision frame
const imageData = 'base64ImageData=='
const detectedValue = 456
const confidence = 0.92
act(() => {
result.current.sendVisionFrame(imageData, detectedValue, confidence)
})
expect(mockSocket.emit).toHaveBeenCalledWith(
'vision-frame',
expect.objectContaining({
sessionId: 'session-123',
imageData: 'base64ImageData==',
detectedValue: 456,
confidence: 0.92,
timestamp: expect.any(Number),
})
)
})
it('includes timestamp in vision-frame event', async () => {
const now = Date.now()
vi.setSystemTime(now)
let connectHandler: (() => void) | undefined
mockSocket.on.mockImplementation((event: string, handler: unknown) => {
if (event === 'connect') {
connectHandler = handler as () => void
}
return mockSocket
})
const { result } = renderHook(() =>
useSessionBroadcast('session-123', 'player-456', createMockBroadcastState())
)
act(() => {
connectHandler?.()
})
act(() => {
result.current.sendVisionFrame('imageData', 123, 0.95)
})
expect(mockSocket.emit).toHaveBeenCalledWith(
'vision-frame',
expect.objectContaining({
timestamp: now,
})
)
vi.useRealTimers()
})
it('handles null detectedValue', async () => {
let connectHandler: (() => void) | undefined
mockSocket.on.mockImplementation((event: string, handler: unknown) => {
if (event === 'connect') {
connectHandler = handler as () => void
}
return mockSocket
})
const { result } = renderHook(() =>
useSessionBroadcast('session-123', 'player-456', createMockBroadcastState())
)
act(() => {
connectHandler?.()
})
act(() => {
result.current.sendVisionFrame('imageData', null, 0)
})
expect(mockSocket.emit).toHaveBeenCalledWith(
'vision-frame',
expect.objectContaining({
detectedValue: null,
confidence: 0,
})
)
})
})
describe('negative cases', () => {
it('does not emit when sessionId is undefined', () => {
const { result } = renderHook(() =>
useSessionBroadcast(undefined, 'player-456', createMockBroadcastState())
)
act(() => {
result.current.sendVisionFrame('imageData', 123, 0.95)
})
expect(mockSocket.emit).not.toHaveBeenCalledWith('vision-frame', expect.anything())
})
it('does not emit when not connected', () => {
// Don't trigger connect handler
const { result } = renderHook(() =>
useSessionBroadcast('session-123', 'player-456', createMockBroadcastState())
)
act(() => {
result.current.sendVisionFrame('imageData', 123, 0.95)
})
// The join-session emit happens on connect, but vision-frame should not
const visionFrameCalls = mockSocket.emit.mock.calls.filter(
([event]) => event === 'vision-frame'
)
expect(visionFrameCalls).toHaveLength(0)
})
it('does not emit when state is null', () => {
const { result } = renderHook(() => useSessionBroadcast('session-123', 'player-456', null))
act(() => {
result.current.sendVisionFrame('imageData', 123, 0.95)
})
// Should still not emit vision-frame (no connection due to null state cleanup logic)
const visionFrameCalls = mockSocket.emit.mock.calls.filter(
([event]) => event === 'vision-frame'
)
expect(visionFrameCalls).toHaveLength(0)
})
})
describe('result interface', () => {
it('includes sendVisionFrame in the result', () => {
const { result } = renderHook(() =>
useSessionBroadcast('session-123', 'player-456', createMockBroadcastState())
)
expect(result.current).toHaveProperty('sendVisionFrame')
expect(result.current).toHaveProperty('isConnected')
expect(result.current).toHaveProperty('isBroadcasting')
expect(result.current).toHaveProperty('sendPartTransition')
expect(result.current).toHaveProperty('sendPartTransitionComplete')
})
})
})

View File

@@ -0,0 +1,255 @@
/**
* Unit tests for useSessionObserver vision frame receiving
*/
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VisionFrameEvent } from '@/lib/classroom/socket-events'
import { useSessionObserver } from '../useSessionObserver'
// Mock socket.io-client
const mockSocket = {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
connected: true,
}
vi.mock('socket.io-client', () => ({
io: vi.fn(() => mockSocket),
}))
describe('useSessionObserver - vision frame receiving', () => {
let eventHandlers: Map<string, (data: unknown) => void>
beforeEach(() => {
vi.clearAllMocks()
eventHandlers = new Map()
// Capture event handlers
mockSocket.on.mockImplementation((event: string, handler: unknown) => {
eventHandlers.set(event, handler as (data: unknown) => void)
return mockSocket
})
})
describe('visionFrame state', () => {
it('initially returns null visionFrame', () => {
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
expect(result.current.visionFrame).toBeNull()
})
it('updates visionFrame when vision-frame event is received', async () => {
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
// Simulate receiving a vision frame event
const visionFrameData: VisionFrameEvent = {
sessionId: 'session-123',
imageData: 'base64ImageData==',
detectedValue: 456,
confidence: 0.92,
timestamp: Date.now(),
}
act(() => {
const handler = eventHandlers.get('vision-frame')
handler?.(visionFrameData)
})
await waitFor(() => {
expect(result.current.visionFrame).not.toBeNull()
expect(result.current.visionFrame?.imageData).toBe('base64ImageData==')
expect(result.current.visionFrame?.detectedValue).toBe(456)
expect(result.current.visionFrame?.confidence).toBe(0.92)
expect(result.current.visionFrame?.receivedAt).toBeDefined()
})
})
it('sets receivedAt to current time when frame is received', async () => {
const now = Date.now()
vi.setSystemTime(now)
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
const visionFrameData: VisionFrameEvent = {
sessionId: 'session-123',
imageData: 'imageData',
detectedValue: 123,
confidence: 0.9,
timestamp: now - 100, // Sent 100ms ago
}
act(() => {
const handler = eventHandlers.get('vision-frame')
handler?.(visionFrameData)
})
await waitFor(() => {
expect(result.current.visionFrame?.receivedAt).toBe(now)
})
vi.useRealTimers()
})
it('updates visionFrame with new frames', async () => {
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
// First frame
act(() => {
const handler = eventHandlers.get('vision-frame')
handler?.({
sessionId: 'session-123',
imageData: 'firstFrame',
detectedValue: 100,
confidence: 0.8,
timestamp: Date.now(),
})
})
await waitFor(() => {
expect(result.current.visionFrame?.detectedValue).toBe(100)
})
// Second frame
act(() => {
const handler = eventHandlers.get('vision-frame')
handler?.({
sessionId: 'session-123',
imageData: 'secondFrame',
detectedValue: 200,
confidence: 0.95,
timestamp: Date.now(),
})
})
await waitFor(() => {
expect(result.current.visionFrame?.detectedValue).toBe(200)
expect(result.current.visionFrame?.imageData).toBe('secondFrame')
})
})
it('handles null detectedValue in frames', async () => {
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
const visionFrameData: VisionFrameEvent = {
sessionId: 'session-123',
imageData: 'imageData',
detectedValue: null,
confidence: 0,
timestamp: Date.now(),
}
act(() => {
const handler = eventHandlers.get('vision-frame')
handler?.(visionFrameData)
})
await waitFor(() => {
expect(result.current.visionFrame?.detectedValue).toBeNull()
expect(result.current.visionFrame?.confidence).toBe(0)
})
})
})
describe('cleanup', () => {
it('clears visionFrame on stopObserving', async () => {
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
// Receive a frame
act(() => {
const handler = eventHandlers.get('vision-frame')
handler?.({
sessionId: 'session-123',
imageData: 'imageData',
detectedValue: 123,
confidence: 0.9,
timestamp: Date.now(),
})
})
await waitFor(() => {
expect(result.current.visionFrame).not.toBeNull()
})
// Stop observing
act(() => {
result.current.stopObserving()
})
await waitFor(() => {
expect(result.current.visionFrame).toBeNull()
})
})
})
describe('result interface', () => {
it('includes visionFrame in the result', () => {
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
expect(result.current).toHaveProperty('visionFrame')
expect(result.current).toHaveProperty('state')
expect(result.current).toHaveProperty('results')
expect(result.current).toHaveProperty('transitionState')
expect(result.current).toHaveProperty('isConnected')
expect(result.current).toHaveProperty('isObserving')
expect(result.current).toHaveProperty('error')
})
})
describe('negative cases', () => {
it('does not update visionFrame when observer is disabled', () => {
const { result } = renderHook(
() => useSessionObserver('session-123', 'observer-456', 'player-789', false) // disabled
)
// The socket won't be created when disabled
expect(eventHandlers.size).toBe(0)
expect(result.current.visionFrame).toBeNull()
})
it('does not update visionFrame when sessionId is undefined', () => {
const { result } = renderHook(() =>
useSessionObserver(undefined, 'observer-456', 'player-789', true)
)
expect(result.current.visionFrame).toBeNull()
expect(result.current.isObserving).toBe(false)
})
it('handles empty imageData gracefully', async () => {
const { result } = renderHook(() =>
useSessionObserver('session-123', 'observer-456', 'player-789', true)
)
act(() => {
const handler = eventHandlers.get('vision-frame')
handler?.({
sessionId: 'session-123',
imageData: '',
detectedValue: 123,
confidence: 0.9,
timestamp: Date.now(),
})
})
await waitFor(() => {
expect(result.current.visionFrame?.imageData).toBe('')
})
})
})
})

View File

@@ -192,6 +192,8 @@ export function useSessionObserver(
setIsObserving(false)
setState(null)
setResults([])
setTransitionState(null)
setVisionFrame(null)
recordedProblemsRef.current.clear()
hasSeededHistoryRef.current = false
}

View File

@@ -1 +1,18 @@
import '@testing-library/jest-dom'
// Mock canvas Image constructor to prevent jsdom errors when rendering
// images with data URIs (e.g., data:image/jpeg;base64,...)
// This works by patching HTMLImageElement.prototype before jsdom uses it
const originalSetAttribute = HTMLImageElement.prototype.setAttribute
HTMLImageElement.prototype.setAttribute = function (name: string, value: string) {
if (name === 'src' && value.startsWith('data:image/')) {
// Store the value but don't trigger jsdom's image loading
Object.defineProperty(this, 'src', {
value,
writable: true,
configurable: true,
})
return
}
return originalSetAttribute.call(this, name, value)
}