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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
432
apps/web/src/contexts/__tests__/MyAbacusContext.vision.test.tsx
Normal file
432
apps/web/src/contexts/__tests__/MyAbacusContext.vision.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
218
apps/web/src/hooks/__tests__/useSessionBroadcast.vision.test.ts
Normal file
218
apps/web/src/hooks/__tests__/useSessionBroadcast.vision.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
255
apps/web/src/hooks/__tests__/useSessionObserver.vision.test.ts
Normal file
255
apps/web/src/hooks/__tests__/useSessionObserver.vision.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -192,6 +192,8 @@ export function useSessionObserver(
|
||||
setIsObserving(false)
|
||||
setState(null)
|
||||
setResults([])
|
||||
setTransitionState(null)
|
||||
setVisionFrame(null)
|
||||
recordedProblemsRef.current.clear()
|
||||
hasSeededHistoryRef.current = false
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user