From 43524d823864e03b8c25e72cd35db8fb1860fc06 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 1 Jan 2026 16:08:51 -0600 Subject: [PATCH] test: add unit tests for vision broadcast feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../__tests__/ObserverVisionFeed.test.tsx | 191 ++++++++ .../vision/__tests__/VisionIndicator.test.tsx | 173 +++++++ .../__tests__/MyAbacusContext.vision.test.tsx | 432 ++++++++++++++++++ .../useSessionBroadcast.vision.test.ts | 218 +++++++++ .../useSessionObserver.vision.test.ts | 255 +++++++++++ apps/web/src/hooks/useSessionObserver.ts | 2 + apps/web/src/test/setup.ts | 17 + 7 files changed, 1288 insertions(+) create mode 100644 apps/web/src/components/vision/__tests__/ObserverVisionFeed.test.tsx create mode 100644 apps/web/src/components/vision/__tests__/VisionIndicator.test.tsx create mode 100644 apps/web/src/contexts/__tests__/MyAbacusContext.vision.test.tsx create mode 100644 apps/web/src/hooks/__tests__/useSessionBroadcast.vision.test.ts create mode 100644 apps/web/src/hooks/__tests__/useSessionObserver.vision.test.ts diff --git a/apps/web/src/components/vision/__tests__/ObserverVisionFeed.test.tsx b/apps/web/src/components/vision/__tests__/ObserverVisionFeed.test.tsx new file mode 100644 index 00000000..ba996fef --- /dev/null +++ b/apps/web/src/components/vision/__tests__/ObserverVisionFeed.test.tsx @@ -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 => ({ + 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() + + expect(screen.getByRole('img')).toBeInTheDocument() + }) + + it('displays the image with correct src', () => { + const frame = createMockFrame({ imageData: 'testImageData123' }) + render() + + 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() + + 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() + + expect(screen.getByText('456')).toBeInTheDocument() + }) + + it('displays confidence percentage', () => { + const frame = createMockFrame({ detectedValue: 123, confidence: 0.87 }) + render() + + expect(screen.getByText('87%')).toBeInTheDocument() + }) + + it('displays dashes when detectedValue is null', () => { + const frame = createMockFrame({ detectedValue: null, confidence: 0 }) + render() + + expect(screen.getByText('---')).toBeInTheDocument() + }) + + it('hides confidence when value is null', () => { + const frame = createMockFrame({ detectedValue: null, confidence: 0.95 }) + render() + + expect(screen.queryByText('95%')).not.toBeInTheDocument() + }) + + it('handles zero as a valid detected value', () => { + const frame = createMockFrame({ detectedValue: 0, confidence: 0.99 }) + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + expect(screen.getByText('88%')).toBeInTheDocument() + }) + + it('handles confidence edge case of exactly 1', () => { + const frame = createMockFrame({ detectedValue: 123, confidence: 1.0 }) + render() + + expect(screen.getByText('100%')).toBeInTheDocument() + }) + + it('handles confidence edge case of exactly 0', () => { + const frame = createMockFrame({ detectedValue: 123, confidence: 0 }) + render() + + expect(screen.getByText('0%')).toBeInTheDocument() + }) + }) +}) diff --git a/apps/web/src/components/vision/__tests__/VisionIndicator.test.tsx b/apps/web/src/components/vision/__tests__/VisionIndicator.test.tsx new file mode 100644 index 00000000..b7010559 --- /dev/null +++ b/apps/web/src/components/vision/__tests__/VisionIndicator.test.tsx @@ -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() + expect(screen.getByText('📷')).toBeInTheDocument() + }) + + it('renders with medium size by default', () => { + render() + 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() + 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() + 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() + 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() + const button = screen.getByRole('button') + expect(button).toHaveAttribute('data-vision-status', 'enabled') + }) + }) + + describe('click behavior', () => { + it('opens setup modal on click', () => { + render() + const button = screen.getByRole('button') + + fireEvent.click(button) + + expect(mockOpenVisionSetup).toHaveBeenCalledTimes(1) + }) + + it('opens setup modal on right-click', () => { + render() + const button = screen.getByRole('button') + + fireEvent.contextMenu(button) + + expect(mockOpenVisionSetup).toHaveBeenCalledTimes(1) + }) + + it('stops event propagation on click', () => { + const parentClickHandler = vi.fn() + + render( +
+ +
+ ) + 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() + 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() + const button = screen.getByRole('button') + + expect(button).toHaveAttribute('title', expect.stringContaining('enabled')) + }) + }) + + describe('positioning', () => { + it('uses bottom-right position by default', () => { + render() + const button = screen.getByRole('button') + + expect(button.style.position).toBe('absolute') + }) + + it('accepts top-left position', () => { + render() + const button = screen.getByRole('button') + + expect(button.style.position).toBe('absolute') + }) + }) +}) diff --git a/apps/web/src/contexts/__tests__/MyAbacusContext.vision.test.tsx b/apps/web/src/contexts/__tests__/MyAbacusContext.vision.test.tsx new file mode 100644 index 00000000..d97b2988 --- /dev/null +++ b/apps/web/src/contexts/__tests__/MyAbacusContext.vision.test.tsx @@ -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 = {} + 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 }) => ( + {children} + ) + + 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') + }) + }) +}) diff --git a/apps/web/src/hooks/__tests__/useSessionBroadcast.vision.test.ts b/apps/web/src/hooks/__tests__/useSessionBroadcast.vision.test.ts new file mode 100644 index 00000000..99064793 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useSessionBroadcast.vision.test.ts @@ -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') + }) + }) +}) diff --git a/apps/web/src/hooks/__tests__/useSessionObserver.vision.test.ts b/apps/web/src/hooks/__tests__/useSessionObserver.vision.test.ts new file mode 100644 index 00000000..1a7cd3ab --- /dev/null +++ b/apps/web/src/hooks/__tests__/useSessionObserver.vision.test.ts @@ -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 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('') + }) + }) + }) +}) diff --git a/apps/web/src/hooks/useSessionObserver.ts b/apps/web/src/hooks/useSessionObserver.ts index 0c1d756f..3e02f27a 100644 --- a/apps/web/src/hooks/useSessionObserver.ts +++ b/apps/web/src/hooks/useSessionObserver.ts @@ -192,6 +192,8 @@ export function useSessionObserver( setIsObserving(false) setState(null) setResults([]) + setTransitionState(null) + setVisionFrame(null) recordedProblemsRef.current.clear() hasSeededHistoryRef.current = false } diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index c44951a6..ba353bcc 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -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) +}