fix(vision): remote camera persistence and UI bugs
- Fix camera source switching: clear remoteCameraSessionId in context when switching to local camera so DockedVisionFeed uses the correct source - Fix modal drag during calibration: disable framer-motion drag when calibration overlay is active to allow handle dragging - Fix initial camera source: pass initialCameraSource prop to AbacusVisionBridge so it shows phone camera when reconfiguring remote - Extend session TTL from 10 to 60 minutes for remote camera sessions - Add localStorage persistence for remote camera session IDs - Add auto-reconnect logic for both desktop and phone hooks - Add comprehensive tests for session-manager, useRemoteCameraDesktop, and useRemoteCameraPhone hooks - Guard test setup.ts for node environment (HTMLImageElement check) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
43524d8238
commit
d90d263b2a
|
|
@ -42,6 +42,8 @@ export interface AbacusVisionBridgeProps {
|
|||
onError?: (error: string) => void
|
||||
/** Called when configuration changes (camera, calibration, or remote session) */
|
||||
onConfigurationChange?: (config: VisionConfigurationChange) => void
|
||||
/** Initial camera source to show (defaults to 'local', but should be 'phone' if remote session is active) */
|
||||
initialCameraSource?: CameraSource
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +61,7 @@ export function AbacusVisionBridge({
|
|||
onClose,
|
||||
onError,
|
||||
onConfigurationChange,
|
||||
initialCameraSource = 'local',
|
||||
}: AbacusVisionBridgeProps): ReactNode {
|
||||
const [videoDimensions, setVideoDimensions] = useState<{
|
||||
width: number
|
||||
|
|
@ -84,7 +87,7 @@ export function AbacusVisionBridge({
|
|||
const [opencvReady, setOpencvReady] = useState(false)
|
||||
|
||||
// Camera source selection
|
||||
const [cameraSource, setCameraSource] = useState<CameraSource>('local')
|
||||
const [cameraSource, setCameraSource] = useState<CameraSource>(initialCameraSource)
|
||||
const [remoteCameraSessionId, setRemoteCameraSessionId] = useState<string | null>(null)
|
||||
|
||||
// Remote camera state
|
||||
|
|
@ -107,12 +110,16 @@ export function AbacusVisionBridge({
|
|||
isTorchOn: remoteIsTorchOn,
|
||||
isTorchAvailable: remoteIsTorchAvailable,
|
||||
error: remoteError,
|
||||
currentSessionId: remoteCurrentSessionId,
|
||||
isReconnecting: remoteIsReconnecting,
|
||||
subscribe: remoteSubscribe,
|
||||
unsubscribe: remoteUnsubscribe,
|
||||
setPhoneFrameMode: remoteSetPhoneFrameMode,
|
||||
sendCalibration: remoteSendCalibration,
|
||||
clearCalibration: remoteClearCalibration,
|
||||
setRemoteTorch,
|
||||
getPersistedSessionId: remoteGetPersistedSessionId,
|
||||
clearSession: remoteClearSession,
|
||||
} = useRemoteCameraDesktop()
|
||||
|
||||
// Stability tracking for remote frames
|
||||
|
|
@ -130,26 +137,56 @@ export function AbacusVisionBridge({
|
|||
const lastReportedCalibrationRef = useRef<CalibrationGrid | null>(null)
|
||||
const lastReportedRemoteSessionRef = useRef<string | null>(null)
|
||||
|
||||
// Initialize remote camera session from localStorage on mount
|
||||
useEffect(() => {
|
||||
const persistedSessionId = remoteGetPersistedSessionId()
|
||||
if (persistedSessionId) {
|
||||
console.log('[AbacusVisionBridge] Found persisted remote session:', persistedSessionId)
|
||||
setRemoteCameraSessionId(persistedSessionId)
|
||||
// Also notify parent about the persisted session
|
||||
// This ensures the parent context stays in sync with localStorage
|
||||
onConfigurationChange?.({ remoteCameraSessionId: persistedSessionId })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remoteGetPersistedSessionId]) // onConfigurationChange is intentionally omitted - only run on mount
|
||||
|
||||
// Handle switching to phone camera
|
||||
const handleCameraSourceChange = useCallback(
|
||||
(source: CameraSource) => {
|
||||
setCameraSource(source)
|
||||
if (source === 'phone') {
|
||||
// Stop local camera - session will be created by RemoteCameraQRCode
|
||||
// Stop local camera
|
||||
vision.disable()
|
||||
// Clear local camera config in parent context
|
||||
onConfigurationChange?.({ cameraDeviceId: null, calibration: null })
|
||||
// Check for persisted session and reuse it
|
||||
const persistedSessionId = remoteGetPersistedSessionId()
|
||||
if (persistedSessionId) {
|
||||
console.log('[AbacusVisionBridge] Reusing persisted remote session:', persistedSessionId)
|
||||
setRemoteCameraSessionId(persistedSessionId)
|
||||
// Notify parent about the reused session
|
||||
onConfigurationChange?.({ remoteCameraSessionId: persistedSessionId })
|
||||
}
|
||||
// If no persisted session, RemoteCameraQRCode will create one
|
||||
} else {
|
||||
// Stop remote session and start local camera
|
||||
// Switching to local camera
|
||||
// Clear remote session in context so DockedVisionFeed uses local camera
|
||||
// The session still persists in localStorage (via useRemoteCameraDesktop) for when we switch back
|
||||
setRemoteCameraSessionId(null)
|
||||
vision.enable()
|
||||
// Clear remote session config in parent context
|
||||
onConfigurationChange?.({ remoteCameraSessionId: null })
|
||||
vision.enable()
|
||||
}
|
||||
},
|
||||
[vision, onConfigurationChange]
|
||||
[vision, onConfigurationChange, remoteGetPersistedSessionId]
|
||||
)
|
||||
|
||||
// Handle starting a fresh session (clear persisted and create new)
|
||||
const handleStartFreshSession = useCallback(() => {
|
||||
console.log('[AbacusVisionBridge] Starting fresh session')
|
||||
remoteClearSession()
|
||||
setRemoteCameraSessionId(null)
|
||||
}, [remoteClearSession])
|
||||
|
||||
// Handle session created by QR code component
|
||||
const handleRemoteSessionCreated = useCallback((sessionId: string) => {
|
||||
setRemoteCameraSessionId(sessionId)
|
||||
|
|
@ -257,9 +294,14 @@ export function AbacusVisionBridge({
|
|||
}, [cameraSource, vision.calibrationGrid, onConfigurationChange])
|
||||
|
||||
// Notify about remote camera session changes
|
||||
// Reset the lastReportedRemoteSessionRef when switching away from phone camera
|
||||
// so that the next time we switch to phone, we'll notify the parent again
|
||||
useEffect(() => {
|
||||
if (
|
||||
cameraSource === 'phone' &&
|
||||
if (cameraSource !== 'phone') {
|
||||
// When switching away from phone camera, reset the ref
|
||||
// This ensures we'll notify the parent again when switching back
|
||||
lastReportedRemoteSessionRef.current = null
|
||||
} else if (
|
||||
remoteCameraSessionId &&
|
||||
remoteCameraSessionId !== lastReportedRemoteSessionRef.current
|
||||
) {
|
||||
|
|
@ -469,11 +511,14 @@ export function AbacusVisionBridge({
|
|||
[vision]
|
||||
)
|
||||
|
||||
// Determine if any calibration is active (disable drag during calibration)
|
||||
const isCalibrating = vision.isCalibrating || remoteIsCalibrating
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
data-component="abacus-vision-bridge"
|
||||
drag
|
||||
drag={!isCalibrating}
|
||||
dragMomentum={false}
|
||||
dragElastic={0}
|
||||
className={css({
|
||||
|
|
@ -485,8 +530,8 @@ export function AbacusVisionBridge({
|
|||
borderRadius: 'xl',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
cursor: 'grab',
|
||||
_active: { cursor: 'grabbing' },
|
||||
cursor: isCalibrating ? 'default' : 'grab',
|
||||
_active: { cursor: isCalibrating ? 'default' : 'grabbing' },
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -588,136 +633,114 @@ export function AbacusVisionBridge({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Camera controls (local camera) - only show if there's something to display */}
|
||||
{cameraSource === 'local' &&
|
||||
(vision.availableDevices.length > 1 || vision.isTorchAvailable) && (
|
||||
<div
|
||||
data-element="camera-controls"
|
||||
{/* Camera controls - unified for both local and phone cameras */}
|
||||
<div
|
||||
data-element="camera-controls"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{/* Camera selector - always show for local camera */}
|
||||
{cameraSource === 'local' && vision.availableDevices.length > 0 && (
|
||||
<select
|
||||
data-element="camera-selector"
|
||||
value={vision.selectedDeviceId ?? ''}
|
||||
onChange={handleCameraSelect}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
p: 2,
|
||||
bg: 'gray.800',
|
||||
color: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
minWidth: '150px',
|
||||
})}
|
||||
>
|
||||
{/* Camera selector (if multiple cameras) */}
|
||||
{vision.availableDevices.length > 1 && (
|
||||
<select
|
||||
data-element="camera-selector"
|
||||
value={vision.selectedDeviceId ?? ''}
|
||||
onChange={handleCameraSelect}
|
||||
className={css({
|
||||
flex: 1,
|
||||
p: 2,
|
||||
bg: 'gray.800',
|
||||
color: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
minWidth: '150px',
|
||||
})}
|
||||
>
|
||||
{vision.availableDevices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `Camera ${device.deviceId.slice(0, 8)}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Flip camera button - only show if multiple cameras available */}
|
||||
{vision.availableDevices.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => vision.flipCamera()}
|
||||
data-action="flip-camera"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'lg',
|
||||
_hover: { bg: 'gray.600' },
|
||||
})}
|
||||
title={`Switch to ${vision.facingMode === 'environment' ? 'front' : 'back'} camera`}
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Torch toggle button (only if available) */}
|
||||
{vision.isTorchAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => vision.toggleTorch()}
|
||||
data-action="toggle-torch"
|
||||
data-status={vision.isTorchOn ? 'on' : 'off'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
bg: vision.isTorchOn ? 'yellow.600' : 'gray.700',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'lg',
|
||||
_hover: { bg: vision.isTorchOn ? 'yellow.500' : 'gray.600' },
|
||||
})}
|
||||
title={vision.isTorchOn ? 'Turn off flash' : 'Turn on flash'}
|
||||
>
|
||||
{vision.isTorchOn ? '🔦' : '💡'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{vision.availableDevices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `Camera ${device.deviceId.slice(0, 8)}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Camera controls (phone camera) */}
|
||||
{cameraSource === 'phone' && remoteIsPhoneConnected && remoteIsTorchAvailable && (
|
||||
<div
|
||||
data-element="phone-camera-controls"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
})}
|
||||
>
|
||||
{/* Remote torch toggle button */}
|
||||
{/* Flip camera button - only show if multiple cameras available */}
|
||||
{cameraSource === 'local' && vision.availableDevices.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoteTorch(!remoteIsTorchOn)}
|
||||
data-action="toggle-remote-torch"
|
||||
data-status={remoteIsTorchOn ? 'on' : 'off'}
|
||||
onClick={() => vision.flipCamera()}
|
||||
data-action="flip-camera"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
bg: remoteIsTorchOn ? 'yellow.600' : 'gray.700',
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'lg',
|
||||
_hover: { bg: remoteIsTorchOn ? 'yellow.500' : 'gray.600' },
|
||||
_hover: { bg: 'gray.600' },
|
||||
})}
|
||||
title={remoteIsTorchOn ? 'Turn off phone flash' : 'Turn on phone flash'}
|
||||
title={`Switch to ${vision.facingMode === 'environment' ? 'front' : 'back'} camera`}
|
||||
>
|
||||
{remoteIsTorchOn ? '🔦' : '💡'}
|
||||
🔄
|
||||
</button>
|
||||
<span className={css({ color: 'gray.400', fontSize: 'sm' })}>Phone Flash</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Torch toggle button - unified for both local and remote */}
|
||||
{((cameraSource === 'local' && vision.isTorchAvailable) ||
|
||||
(cameraSource === 'phone' && remoteIsPhoneConnected && remoteIsTorchAvailable)) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (cameraSource === 'local') {
|
||||
vision.toggleTorch()
|
||||
} else {
|
||||
setRemoteTorch(!remoteIsTorchOn)
|
||||
}
|
||||
}}
|
||||
data-action="toggle-torch"
|
||||
data-status={
|
||||
(cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn) ? 'on' : 'off'
|
||||
}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
bg: (cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn)
|
||||
? 'yellow.600'
|
||||
: 'gray.700',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'lg',
|
||||
_hover: {
|
||||
bg: (cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn)
|
||||
? 'yellow.500'
|
||||
: 'gray.600',
|
||||
},
|
||||
})}
|
||||
title={
|
||||
(cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn)
|
||||
? 'Turn off flash'
|
||||
: 'Turn on flash'
|
||||
}
|
||||
>
|
||||
{(cameraSource === 'local' ? vision.isTorchOn : remoteIsTorchOn) ? '🔦' : '💡'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calibration mode toggle (both local and phone camera) */}
|
||||
<div
|
||||
|
|
@ -951,12 +974,60 @@ export function AbacusVisionBridge({
|
|||
color: 'gray.400',
|
||||
})}
|
||||
>
|
||||
<p className={css({ mb: 4 })}>Waiting for phone to connect...</p>
|
||||
{remoteIsReconnecting ? (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
color: 'blue.300',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 'full',
|
||||
bg: 'blue.400',
|
||||
animation: 'pulse 1.5s infinite',
|
||||
})}
|
||||
/>
|
||||
Reconnecting to session...
|
||||
</div>
|
||||
<style>{`@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }`}</style>
|
||||
</>
|
||||
) : (
|
||||
<p className={css({ mb: 2 })}>Waiting for phone to connect...</p>
|
||||
)}
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.500', mb: 4 })}>
|
||||
Session: {remoteCameraSessionId.slice(0, 8)}...
|
||||
</p>
|
||||
<RemoteCameraQRCode
|
||||
onSessionCreated={handleRemoteSessionCreated}
|
||||
existingSessionId={remoteCameraSessionId}
|
||||
size={150}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartFreshSession}
|
||||
className={css({
|
||||
mt: 4,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontSize: 'xs',
|
||||
color: 'gray.400',
|
||||
bg: 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'gray.700', color: 'white' },
|
||||
})}
|
||||
>
|
||||
Start Fresh Session
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Show camera frames */
|
||||
|
|
|
|||
|
|
@ -215,6 +215,10 @@ export function VisionSetupModal() {
|
|||
setVisionRemoteSession(config.remoteCameraSessionId)
|
||||
}
|
||||
}}
|
||||
// Start with phone camera selected if remote session is configured but no local camera
|
||||
initialCameraSource={
|
||||
visionConfig.remoteCameraSessionId && !visionConfig.cameraDeviceId ? 'phone' : 'local'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,501 @@
|
|||
/**
|
||||
* Tests for useRemoteCameraDesktop hook
|
||||
*
|
||||
* Tests session persistence, auto-reconnection, and Socket.IO event handling.
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRemoteCameraDesktop } from '../useRemoteCameraDesktop'
|
||||
|
||||
// Mock socket.io-client - use vi.hoisted for variables referenced in vi.mock
|
||||
const { mockSocket, mockIo } = vi.hoisted(() => {
|
||||
const socket = {
|
||||
id: 'test-socket-id',
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connected: true,
|
||||
}
|
||||
return {
|
||||
mockSocket: socket,
|
||||
mockIo: vi.fn(() => socket),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: mockIo,
|
||||
}))
|
||||
|
||||
// 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('useRemoteCameraDesktop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
// Reset mock socket handlers
|
||||
mockIo.mockClear()
|
||||
mockSocket.on.mockClear()
|
||||
mockSocket.off.mockClear()
|
||||
mockSocket.emit.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
expect(result.current.isPhoneConnected).toBe(false)
|
||||
expect(result.current.latestFrame).toBeNull()
|
||||
expect(result.current.frameRate).toBe(0)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.currentSessionId).toBeNull()
|
||||
expect(result.current.isReconnecting).toBe(false)
|
||||
})
|
||||
|
||||
it('should set up socket with reconnection config', () => {
|
||||
renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
expect(mockIo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/socket',
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: 10,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('localStorage persistence', () => {
|
||||
it('should persist session ID when subscribing', async () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
// Simulate socket connect
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
if (connectHandler) {
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
}
|
||||
|
||||
// Subscribe to a session
|
||||
act(() => {
|
||||
result.current.subscribe('test-session-123')
|
||||
})
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'remote-camera-session-id',
|
||||
'test-session-123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return persisted session ID from getPersistedSessionId', () => {
|
||||
localStorageMock.getItem.mockReturnValue('persisted-session-456')
|
||||
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const persistedId = result.current.getPersistedSessionId()
|
||||
|
||||
expect(persistedId).toBe('persisted-session-456')
|
||||
})
|
||||
|
||||
it('should clear persisted session ID on clearSession', async () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
act(() => {
|
||||
result.current.clearSession()
|
||||
})
|
||||
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('remote-camera-session-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-reconnect on socket reconnect', () => {
|
||||
it('should re-subscribe to persisted session on socket connect', () => {
|
||||
localStorageMock.getItem.mockReturnValue('persisted-session-789')
|
||||
|
||||
renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
// Find the connect handler
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
expect(connectHandler).toBeDefined()
|
||||
|
||||
// Simulate socket connect
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
// Should emit subscribe with persisted session
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:subscribe', {
|
||||
sessionId: 'persisted-session-789',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not subscribe if no persisted session', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
// Should not emit subscribe
|
||||
expect(mockSocket.emit).not.toHaveBeenCalledWith(
|
||||
'remote-camera:subscribe',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session subscription', () => {
|
||||
it('should emit subscribe event with session ID', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
// Simulate connection
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe('new-session-id')
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:subscribe', {
|
||||
sessionId: 'new-session-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update currentSessionId on subscribe', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
// Simulate connection
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe('my-session')
|
||||
})
|
||||
|
||||
expect(result.current.currentSessionId).toBe('my-session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('event handling', () => {
|
||||
it('should handle phone connected event', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
// Find the event handler setup
|
||||
const setupHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:connected'
|
||||
)?.[1]
|
||||
|
||||
if (setupHandler) {
|
||||
act(() => {
|
||||
setupHandler({ phoneConnected: true })
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.isPhoneConnected).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle phone disconnected event', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
// Set connected first
|
||||
const connectedHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:connected'
|
||||
)?.[1]
|
||||
if (connectedHandler) {
|
||||
act(() => {
|
||||
connectedHandler({ phoneConnected: true })
|
||||
})
|
||||
}
|
||||
|
||||
// Then disconnect
|
||||
const disconnectedHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:disconnected'
|
||||
)?.[1]
|
||||
if (disconnectedHandler) {
|
||||
act(() => {
|
||||
disconnectedHandler({ phoneConnected: false })
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.isPhoneConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle frame events', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const frameHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:frame'
|
||||
)?.[1]
|
||||
|
||||
const testFrame = {
|
||||
imageData: 'base64-image-data',
|
||||
timestamp: Date.now(),
|
||||
mode: 'cropped' as const,
|
||||
}
|
||||
|
||||
if (frameHandler) {
|
||||
act(() => {
|
||||
frameHandler(testFrame)
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.latestFrame).toEqual(testFrame)
|
||||
})
|
||||
|
||||
it('should handle error events and clear invalid sessions', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const errorHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:error'
|
||||
)?.[1]
|
||||
|
||||
if (errorHandler) {
|
||||
act(() => {
|
||||
errorHandler({ error: 'Invalid session' })
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.error).toBe('Invalid session')
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('remote-camera-session-id')
|
||||
})
|
||||
|
||||
it('should handle torch state events', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const torchHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:torch-state'
|
||||
)?.[1]
|
||||
|
||||
if (torchHandler) {
|
||||
act(() => {
|
||||
torchHandler({ isTorchOn: true, isTorchAvailable: true })
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.isTorchOn).toBe(true)
|
||||
expect(result.current.isTorchAvailable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calibration commands', () => {
|
||||
it('should emit calibration to phone', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
// Simulate connection and subscription
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('calibration-session')
|
||||
})
|
||||
|
||||
const corners = {
|
||||
topLeft: { x: 0, y: 0 },
|
||||
topRight: { x: 100, y: 0 },
|
||||
bottomLeft: { x: 0, y: 100 },
|
||||
bottomRight: { x: 100, y: 100 },
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.sendCalibration(corners)
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:set-calibration', {
|
||||
sessionId: 'calibration-session',
|
||||
corners,
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit clear calibration to phone', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('clear-cal-session')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.clearCalibration()
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:clear-calibration', {
|
||||
sessionId: 'clear-cal-session',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('frame mode control', () => {
|
||||
it('should emit frame mode change to phone', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('mode-session')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setPhoneFrameMode('raw')
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:set-mode', {
|
||||
sessionId: 'mode-session',
|
||||
mode: 'raw',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('torch control', () => {
|
||||
it('should emit torch command to phone', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('torch-session')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setRemoteTorch(true)
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:set-torch', {
|
||||
sessionId: 'torch-session',
|
||||
on: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should optimistically update torch state', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('torch-session-2')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setRemoteTorch(true)
|
||||
})
|
||||
|
||||
expect(result.current.isTorchOn).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should emit leave on unsubscribe', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('leave-session')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.unsubscribe()
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:leave', {
|
||||
sessionId: 'leave-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset state on unsubscribe', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('reset-session')
|
||||
})
|
||||
|
||||
// Set some state
|
||||
const connectedHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:connected'
|
||||
)?.[1]
|
||||
if (connectedHandler) {
|
||||
act(() => {
|
||||
connectedHandler({ phoneConnected: true })
|
||||
})
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.unsubscribe()
|
||||
})
|
||||
|
||||
expect(result.current.isPhoneConnected).toBe(false)
|
||||
expect(result.current.latestFrame).toBeNull()
|
||||
expect(result.current.frameRate).toBe(0)
|
||||
})
|
||||
|
||||
it('should clear all state on clearSession', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraDesktop())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
act(() => {
|
||||
result.current.subscribe('clear-session')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.clearSession()
|
||||
})
|
||||
|
||||
expect(result.current.currentSessionId).toBeNull()
|
||||
expect(result.current.isPhoneConnected).toBe(false)
|
||||
expect(result.current.isReconnecting).toBe(false)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('remote-camera-session-id')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
/**
|
||||
* Tests for useRemoteCameraPhone hook
|
||||
*
|
||||
* Tests socket connection, auto-reconnection, and frame sending behavior.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRemoteCameraPhone } from '../useRemoteCameraPhone'
|
||||
|
||||
// Mock socket.io-client - use vi.hoisted for variables referenced in vi.mock
|
||||
const { mockSocket, mockIo } = vi.hoisted(() => {
|
||||
const socket = {
|
||||
id: 'test-phone-socket-id',
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connected: true,
|
||||
}
|
||||
return {
|
||||
mockSocket: socket,
|
||||
mockIo: vi.fn(() => socket),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: mockIo,
|
||||
}))
|
||||
|
||||
// Mock OpenCV loading
|
||||
vi.mock('@/lib/vision/perspectiveTransform', () => ({
|
||||
loadOpenCV: vi.fn(() => Promise.resolve()),
|
||||
isOpenCVReady: vi.fn(() => true),
|
||||
rectifyQuadrilateralToBase64: vi.fn(() => 'mock-base64-image'),
|
||||
}))
|
||||
|
||||
describe('useRemoteCameraPhone', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIo.mockClear()
|
||||
mockSocket.on.mockClear()
|
||||
mockSocket.off.mockClear()
|
||||
mockSocket.emit.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default state', async () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
expect(result.current.isConnected).toBe(false)
|
||||
expect(result.current.isSending).toBe(false)
|
||||
expect(result.current.frameMode).toBe('raw')
|
||||
expect(result.current.desktopCalibration).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should set up socket with reconnection config', () => {
|
||||
renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
expect(mockIo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/socket',
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: 10,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session connection', () => {
|
||||
it('should emit join event when connecting', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
// Simulate socket connect
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
if (connectHandler) {
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.connect('phone-session-123')
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:join', {
|
||||
sessionId: 'phone-session-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update isConnected on connect', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
if (connectHandler) {
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.connect('connect-session')
|
||||
})
|
||||
|
||||
expect(result.current.isConnected).toBe(true)
|
||||
})
|
||||
|
||||
it('should set error if socket not connected', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
// Don't simulate connect - socket is not connected
|
||||
|
||||
act(() => {
|
||||
result.current.connect('fail-session')
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Socket not connected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-reconnect on socket reconnect', () => {
|
||||
it('should re-join session on socket reconnect', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
// Initial connect
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
// Connect to session
|
||||
act(() => {
|
||||
result.current.connect('reconnect-session')
|
||||
})
|
||||
|
||||
// Clear emit calls
|
||||
mockSocket.emit.mockClear()
|
||||
|
||||
// Simulate socket reconnect (connect event fires again)
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
// Should auto-rejoin
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:join', {
|
||||
sessionId: 'reconnect-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not rejoin if no session was set', () => {
|
||||
renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
mockSocket.emit.mockClear()
|
||||
|
||||
// Simulate reconnect without ever connecting to a session
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).not.toHaveBeenCalledWith('remote-camera:join', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('socket disconnect handling', () => {
|
||||
it('should not clear session on temporary disconnect', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.connect('persist-session')
|
||||
})
|
||||
|
||||
// Simulate temporary disconnect
|
||||
const disconnectHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'disconnect'
|
||||
)?.[1]
|
||||
act(() => {
|
||||
disconnectHandler('transport close')
|
||||
})
|
||||
|
||||
// Session ref should still be set (will reconnect)
|
||||
// isConnected might be false but session should persist internally
|
||||
})
|
||||
|
||||
it('should clear state on server disconnect', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.connect('server-disconnect-session')
|
||||
})
|
||||
|
||||
const disconnectHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'disconnect'
|
||||
)?.[1]
|
||||
act(() => {
|
||||
disconnectHandler('io server disconnect')
|
||||
})
|
||||
|
||||
expect(result.current.isConnected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop commands', () => {
|
||||
it('should handle set-mode command from desktop', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const setModeHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:set-mode'
|
||||
)?.[1]
|
||||
|
||||
if (setModeHandler) {
|
||||
act(() => {
|
||||
setModeHandler({ mode: 'cropped' })
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.frameMode).toBe('cropped')
|
||||
})
|
||||
|
||||
it('should handle set-calibration command from desktop', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const calibrationHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:set-calibration'
|
||||
)?.[1]
|
||||
|
||||
const corners = {
|
||||
topLeft: { x: 10, y: 10 },
|
||||
topRight: { x: 100, y: 10 },
|
||||
bottomLeft: { x: 10, y: 100 },
|
||||
bottomRight: { x: 100, y: 100 },
|
||||
}
|
||||
|
||||
if (calibrationHandler) {
|
||||
act(() => {
|
||||
calibrationHandler({ corners })
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.desktopCalibration).toEqual(corners)
|
||||
// Should auto-switch to cropped mode
|
||||
expect(result.current.frameMode).toBe('cropped')
|
||||
})
|
||||
|
||||
it('should handle clear-calibration command from desktop', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
// First set calibration
|
||||
const calibrationHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:set-calibration'
|
||||
)?.[1]
|
||||
if (calibrationHandler) {
|
||||
act(() => {
|
||||
calibrationHandler({
|
||||
corners: {
|
||||
topLeft: { x: 0, y: 0 },
|
||||
topRight: { x: 100, y: 0 },
|
||||
bottomLeft: { x: 0, y: 100 },
|
||||
bottomRight: { x: 100, y: 100 },
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.desktopCalibration).not.toBeNull()
|
||||
|
||||
// Then clear it
|
||||
const clearHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:clear-calibration'
|
||||
)?.[1]
|
||||
if (clearHandler) {
|
||||
act(() => {
|
||||
clearHandler()
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.desktopCalibration).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle set-torch command from desktop', () => {
|
||||
const torchCallback = vi.fn()
|
||||
renderHook(() => useRemoteCameraPhone({ onTorchRequest: torchCallback }))
|
||||
|
||||
const torchHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:set-torch'
|
||||
)?.[1]
|
||||
|
||||
if (torchHandler) {
|
||||
act(() => {
|
||||
torchHandler({ on: true })
|
||||
})
|
||||
}
|
||||
|
||||
expect(torchCallback).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frame mode', () => {
|
||||
it('should allow setting frame mode locally', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
act(() => {
|
||||
result.current.setFrameMode('cropped')
|
||||
})
|
||||
|
||||
expect(result.current.frameMode).toBe('cropped')
|
||||
})
|
||||
})
|
||||
|
||||
describe('torch state emission', () => {
|
||||
it('should emit torch state to desktop', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.connect('torch-emit-session')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.emitTorchState(true, true)
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:torch-state', {
|
||||
sessionId: 'torch-emit-session',
|
||||
isTorchOn: true,
|
||||
isTorchAvailable: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should emit leave event on disconnect', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.connect('disconnect-session')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.disconnect()
|
||||
})
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('remote-camera:leave', {
|
||||
sessionId: 'disconnect-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset state on disconnect', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.connect('reset-disconnect-session')
|
||||
})
|
||||
|
||||
expect(result.current.isConnected).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.disconnect()
|
||||
})
|
||||
|
||||
expect(result.current.isConnected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle error events', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const errorHandler = mockSocket.on.mock.calls.find(
|
||||
(call) => call[0] === 'remote-camera:error'
|
||||
)?.[1]
|
||||
|
||||
if (errorHandler) {
|
||||
act(() => {
|
||||
errorHandler({ error: 'Session expired' })
|
||||
})
|
||||
}
|
||||
|
||||
expect(result.current.error).toBe('Session expired')
|
||||
expect(result.current.isConnected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calibration update', () => {
|
||||
it('should update calibration for frame processing', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const newCalibration = {
|
||||
topLeft: { x: 20, y: 20 },
|
||||
topRight: { x: 200, y: 20 },
|
||||
bottomLeft: { x: 20, y: 200 },
|
||||
bottomRight: { x: 200, y: 200 },
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.updateCalibration(newCalibration)
|
||||
})
|
||||
|
||||
// The calibration is stored in a ref for frame processing
|
||||
// We can verify by checking that no error is thrown
|
||||
})
|
||||
})
|
||||
|
||||
describe('sending frames', () => {
|
||||
it('should set isSending when startSending is called', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.connect('sending-session')
|
||||
})
|
||||
|
||||
// Create mock video element
|
||||
const mockVideo = document.createElement('video')
|
||||
|
||||
act(() => {
|
||||
result.current.startSending(mockVideo)
|
||||
})
|
||||
|
||||
expect(result.current.isSending).toBe(true)
|
||||
})
|
||||
|
||||
it('should set error if not connected when starting to send', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const mockVideo = document.createElement('video')
|
||||
|
||||
act(() => {
|
||||
result.current.startSending(mockVideo)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Not connected to session')
|
||||
})
|
||||
|
||||
it('should reset isSending on stopSending', () => {
|
||||
const { result } = renderHook(() => useRemoteCameraPhone())
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]
|
||||
act(() => {
|
||||
connectHandler()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.connect('stop-sending-session')
|
||||
})
|
||||
|
||||
const mockVideo = document.createElement('video')
|
||||
act(() => {
|
||||
result.current.startSending(mockVideo)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.stopSending()
|
||||
})
|
||||
|
||||
expect(result.current.isSending).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -162,6 +162,9 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn {
|
|||
video: {
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1440 },
|
||||
// Prefer widest angle lens (zoom: 1 = no zoom = widest)
|
||||
// @ts-expect-error - zoom is valid but not in TS types
|
||||
zoom: { ideal: 1 },
|
||||
// Try to disable face-tracking auto-focus (not all cameras support this)
|
||||
// @ts-expect-error - focusMode is valid but not in TS types
|
||||
focusMode: 'continuous',
|
||||
|
|
|
|||
|
|
@ -117,11 +117,14 @@ export function usePhoneCamera(options: UsePhoneCameraOptions = {}): UsePhoneCam
|
|||
}
|
||||
|
||||
// Request camera with specified facing mode
|
||||
// Prefer widest angle lens (zoom: 1 = no zoom = widest)
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: { ideal: targetFacingMode },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
// @ts-expect-error - zoom is valid but not in TS types
|
||||
zoom: { ideal: 1 },
|
||||
},
|
||||
audio: false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import type { QuadCorners } from '@/types/vision'
|
|||
/** Frame mode: raw sends uncropped frames, cropped applies calibration */
|
||||
export type FrameMode = 'raw' | 'cropped'
|
||||
|
||||
/** LocalStorage key for persisting session ID */
|
||||
const STORAGE_KEY = 'remote-camera-session-id'
|
||||
|
||||
interface RemoteCameraFrame {
|
||||
imageData: string // Base64 JPEG
|
||||
timestamp: number
|
||||
|
|
@ -31,6 +34,10 @@ interface UseRemoteCameraDesktopReturn {
|
|||
isTorchAvailable: boolean
|
||||
/** Error message if connection failed */
|
||||
error: string | null
|
||||
/** Current session ID (null if not subscribed) */
|
||||
currentSessionId: string | null
|
||||
/** Whether actively trying to reconnect */
|
||||
isReconnecting: boolean
|
||||
/** Subscribe to receive frames for a session */
|
||||
subscribe: (sessionId: string) => void
|
||||
/** Unsubscribe from the session */
|
||||
|
|
@ -43,6 +50,10 @@ interface UseRemoteCameraDesktopReturn {
|
|||
clearCalibration: () => void
|
||||
/** Set phone's torch state */
|
||||
setRemoteTorch: (on: boolean) => void
|
||||
/** Get the persisted session ID (if any) */
|
||||
getPersistedSessionId: () => string | null
|
||||
/** Clear persisted session and disconnect */
|
||||
clearSession: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,22 +77,55 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
const [isTorchOn, setIsTorchOn] = useState(false)
|
||||
const [isTorchAvailable, setIsTorchAvailable] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const currentSessionId = useRef<string | null>(null)
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
|
||||
const [isReconnecting, setIsReconnecting] = useState(false)
|
||||
|
||||
// Refs for values needed in callbacks
|
||||
const currentSessionIdRef = useRef<string | null>(null)
|
||||
const reconnectAttemptRef = useRef(0)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Frame rate calculation
|
||||
const frameTimestamps = useRef<number[]>([])
|
||||
|
||||
// Initialize socket connection
|
||||
// Helper to persist session ID
|
||||
const persistSessionId = useCallback((sessionId: string | null) => {
|
||||
if (sessionId) {
|
||||
localStorage.setItem(STORAGE_KEY, sessionId)
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Helper to get persisted session ID
|
||||
const getPersistedSessionId = useCallback((): string | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
}, [])
|
||||
|
||||
// Initialize socket connection with reconnection support
|
||||
useEffect(() => {
|
||||
console.log('[RemoteCameraDesktop] Initializing socket connection...')
|
||||
const socketInstance = io({
|
||||
path: '/api/socket',
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: 10,
|
||||
})
|
||||
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('[RemoteCameraDesktop] Socket connected! ID:', socketInstance.id)
|
||||
setIsConnected(true)
|
||||
|
||||
// If we have a session ID (either from state or localStorage), re-subscribe
|
||||
const sessionId = currentSessionIdRef.current || getPersistedSessionId()
|
||||
if (sessionId) {
|
||||
console.log('[RemoteCameraDesktop] Re-subscribing to session after reconnect:', sessionId)
|
||||
setIsReconnecting(true)
|
||||
socketInstance.emit('remote-camera:subscribe', { sessionId })
|
||||
}
|
||||
})
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
|
|
@ -91,6 +135,11 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
socketInstance.on('disconnect', (reason) => {
|
||||
console.log('[RemoteCameraDesktop] Socket disconnected:', reason)
|
||||
setIsConnected(false)
|
||||
// Don't clear phone connected state immediately - might reconnect
|
||||
if (reason === 'io server disconnect') {
|
||||
// Server forced disconnect - clear state
|
||||
setIsPhoneConnected(false)
|
||||
}
|
||||
})
|
||||
|
||||
setSocket(socketInstance)
|
||||
|
|
@ -98,7 +147,7 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
return () => {
|
||||
socketInstance.disconnect()
|
||||
}
|
||||
}, [])
|
||||
}, [getPersistedSessionId])
|
||||
|
||||
const calculateFrameRate = useCallback(() => {
|
||||
const now = Date.now()
|
||||
|
|
@ -114,19 +163,23 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
const handleConnected = ({ phoneConnected }: { phoneConnected: boolean }) => {
|
||||
console.log('[RemoteCameraDesktop] Phone connected event:', phoneConnected)
|
||||
setIsPhoneConnected(phoneConnected)
|
||||
setIsReconnecting(false)
|
||||
setError(null)
|
||||
reconnectAttemptRef.current = 0
|
||||
}
|
||||
|
||||
const handleDisconnected = ({ phoneConnected }: { phoneConnected: boolean }) => {
|
||||
console.log('[RemoteCameraDesktop] Phone disconnected event:', phoneConnected)
|
||||
setIsPhoneConnected(phoneConnected)
|
||||
setLatestFrame(null)
|
||||
setFrameRate(0)
|
||||
// Don't clear frame/framerate - keep last state for visual continuity
|
||||
// Phone might reconnect quickly
|
||||
}
|
||||
|
||||
const handleStatus = ({ phoneConnected }: { phoneConnected: boolean }) => {
|
||||
console.log('[RemoteCameraDesktop] Status event:', phoneConnected)
|
||||
setIsPhoneConnected(phoneConnected)
|
||||
setIsReconnecting(false)
|
||||
reconnectAttemptRef.current = 0
|
||||
}
|
||||
|
||||
const handleFrame = (frame: RemoteCameraFrame) => {
|
||||
|
|
@ -145,7 +198,16 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
}
|
||||
|
||||
const handleError = ({ error: errorMsg }: { error: string }) => {
|
||||
console.log('[RemoteCameraDesktop] Error event:', errorMsg)
|
||||
// If session is invalid/expired, clear the persisted session
|
||||
if (errorMsg.includes('Invalid') || errorMsg.includes('expired')) {
|
||||
console.log('[RemoteCameraDesktop] Session invalid, clearing persisted session')
|
||||
persistSessionId(null)
|
||||
setCurrentSessionId(null)
|
||||
currentSessionIdRef.current = null
|
||||
}
|
||||
setError(errorMsg)
|
||||
setIsReconnecting(false)
|
||||
}
|
||||
|
||||
const handleTorchState = ({
|
||||
|
|
@ -174,7 +236,7 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
socket.off('remote-camera:error', handleError)
|
||||
socket.off('remote-camera:torch-state', handleTorchState)
|
||||
}
|
||||
}, [socket, calculateFrameRate])
|
||||
}, [socket, calculateFrameRate, persistSessionId])
|
||||
|
||||
// Frame rate update interval
|
||||
useEffect(() => {
|
||||
|
|
@ -198,19 +260,23 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
return
|
||||
}
|
||||
|
||||
currentSessionId.current = sessionId
|
||||
currentSessionIdRef.current = sessionId
|
||||
setCurrentSessionId(sessionId)
|
||||
persistSessionId(sessionId)
|
||||
setError(null)
|
||||
console.log('[RemoteCameraDesktop] Emitting remote-camera:subscribe')
|
||||
socket.emit('remote-camera:subscribe', { sessionId })
|
||||
},
|
||||
[socket, isConnected]
|
||||
[socket, isConnected, persistSessionId]
|
||||
)
|
||||
|
||||
const unsubscribe = useCallback(() => {
|
||||
if (!socket || !currentSessionId.current) return
|
||||
if (!socket || !currentSessionIdRef.current) return
|
||||
|
||||
socket.emit('remote-camera:leave', { sessionId: currentSessionId.current })
|
||||
currentSessionId.current = null
|
||||
socket.emit('remote-camera:leave', { sessionId: currentSessionIdRef.current })
|
||||
currentSessionIdRef.current = null
|
||||
setCurrentSessionId(null)
|
||||
// Don't clear persisted session - unsubscribe is for temporary disconnect
|
||||
setIsPhoneConnected(false)
|
||||
setLatestFrame(null)
|
||||
setFrameRate(0)
|
||||
|
|
@ -221,6 +287,28 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
setIsTorchAvailable(false)
|
||||
}, [socket])
|
||||
|
||||
/**
|
||||
* Clear session completely (forget persisted session)
|
||||
* Use when user explicitly wants to start fresh
|
||||
*/
|
||||
const clearSession = useCallback(() => {
|
||||
if (socket && currentSessionIdRef.current) {
|
||||
socket.emit('remote-camera:leave', { sessionId: currentSessionIdRef.current })
|
||||
}
|
||||
currentSessionIdRef.current = null
|
||||
setCurrentSessionId(null)
|
||||
persistSessionId(null)
|
||||
setIsPhoneConnected(false)
|
||||
setLatestFrame(null)
|
||||
setFrameRate(0)
|
||||
setError(null)
|
||||
setVideoDimensions(null)
|
||||
setFrameMode('raw')
|
||||
setIsTorchOn(false)
|
||||
setIsTorchAvailable(false)
|
||||
setIsReconnecting(false)
|
||||
}, [socket, persistSessionId])
|
||||
|
||||
/**
|
||||
* Set the phone's frame mode
|
||||
* - raw: Phone sends uncropped frames (for calibration)
|
||||
|
|
@ -228,10 +316,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
*/
|
||||
const setPhoneFrameMode = useCallback(
|
||||
(mode: FrameMode) => {
|
||||
if (!socket || !currentSessionId.current) return
|
||||
if (!socket || !currentSessionIdRef.current) return
|
||||
|
||||
socket.emit('remote-camera:set-mode', {
|
||||
sessionId: currentSessionId.current,
|
||||
sessionId: currentSessionIdRef.current,
|
||||
mode,
|
||||
})
|
||||
setFrameMode(mode)
|
||||
|
|
@ -245,10 +333,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
*/
|
||||
const sendCalibration = useCallback(
|
||||
(corners: QuadCorners) => {
|
||||
if (!socket || !currentSessionId.current) return
|
||||
if (!socket || !currentSessionIdRef.current) return
|
||||
|
||||
socket.emit('remote-camera:set-calibration', {
|
||||
sessionId: currentSessionId.current,
|
||||
sessionId: currentSessionIdRef.current,
|
||||
corners,
|
||||
})
|
||||
// Phone will automatically switch to cropped mode when it receives calibration
|
||||
|
|
@ -262,10 +350,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
* This tells the phone to forget the desktop calibration and go back to auto-detection
|
||||
*/
|
||||
const clearCalibration = useCallback(() => {
|
||||
if (!socket || !currentSessionId.current) return
|
||||
if (!socket || !currentSessionIdRef.current) return
|
||||
|
||||
socket.emit('remote-camera:clear-calibration', {
|
||||
sessionId: currentSessionId.current,
|
||||
sessionId: currentSessionIdRef.current,
|
||||
})
|
||||
}, [socket])
|
||||
|
||||
|
|
@ -274,10 +362,10 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
*/
|
||||
const setRemoteTorch = useCallback(
|
||||
(on: boolean) => {
|
||||
if (!socket || !currentSessionId.current) return
|
||||
if (!socket || !currentSessionIdRef.current) return
|
||||
|
||||
socket.emit('remote-camera:set-torch', {
|
||||
sessionId: currentSessionId.current,
|
||||
sessionId: currentSessionIdRef.current,
|
||||
on,
|
||||
})
|
||||
// Optimistically update local state
|
||||
|
|
@ -289,9 +377,9 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (socket && currentSessionId.current) {
|
||||
if (socket && currentSessionIdRef.current) {
|
||||
socket.emit('remote-camera:leave', {
|
||||
sessionId: currentSessionId.current,
|
||||
sessionId: currentSessionIdRef.current,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -306,11 +394,15 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
|
|||
isTorchOn,
|
||||
isTorchAvailable,
|
||||
error,
|
||||
currentSessionId,
|
||||
isReconnecting,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
setPhoneFrameMode,
|
||||
sendCalibration,
|
||||
clearCalibration,
|
||||
setRemoteTorch,
|
||||
getPersistedSessionId,
|
||||
clearSession,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,17 +118,30 @@ export function useRemoteCameraPhone(
|
|||
frameModeRef.current = frameMode
|
||||
}, [frameMode])
|
||||
|
||||
// Initialize socket connection
|
||||
// Initialize socket connection with reconnection support
|
||||
useEffect(() => {
|
||||
console.log('[RemoteCameraPhone] Initializing socket connection...')
|
||||
const socketInstance = io({
|
||||
path: '/api/socket',
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: 10,
|
||||
})
|
||||
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('[RemoteCameraPhone] Socket connected! ID:', socketInstance.id)
|
||||
setIsSocketConnected(true)
|
||||
|
||||
// Auto-reconnect to session if we have one
|
||||
const sessionId = sessionIdRef.current
|
||||
if (sessionId) {
|
||||
console.log('[RemoteCameraPhone] Auto-reconnecting to session after socket reconnect:', sessionId)
|
||||
socketInstance.emit('remote-camera:join', { sessionId })
|
||||
setIsConnected(true)
|
||||
isConnectedRef.current = true
|
||||
}
|
||||
})
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
|
|
@ -138,8 +151,12 @@ export function useRemoteCameraPhone(
|
|||
socketInstance.on('disconnect', (reason) => {
|
||||
console.log('[RemoteCameraPhone] Socket disconnected:', reason)
|
||||
setIsSocketConnected(false)
|
||||
setIsConnected(false)
|
||||
isConnectedRef.current = false
|
||||
// Don't clear isConnected or sessionIdRef - we want to auto-reconnect
|
||||
// Only clear if server explicitly disconnected us
|
||||
if (reason === 'io server disconnect') {
|
||||
setIsConnected(false)
|
||||
isConnectedRef.current = false
|
||||
}
|
||||
})
|
||||
|
||||
socketRef.current = socketInstance
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Tests for Remote Camera Session Manager
|
||||
*
|
||||
* Tests session creation, TTL management, activity-based renewal,
|
||||
* and calibration persistence.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createRemoteCameraSession,
|
||||
deleteRemoteCameraSession,
|
||||
getOrCreateSession,
|
||||
getRemoteCameraSession,
|
||||
getSessionCalibration,
|
||||
getSessionCount,
|
||||
markPhoneConnected,
|
||||
markPhoneDisconnected,
|
||||
renewSessionTTL,
|
||||
setSessionCalibration,
|
||||
} from '../session-manager'
|
||||
|
||||
describe('Remote Camera Session Manager', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all sessions before each test
|
||||
// Access the global sessions map directly
|
||||
if (globalThis.__remoteCameraSessions) {
|
||||
globalThis.__remoteCameraSessions.clear()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('createRemoteCameraSession', () => {
|
||||
it('should create a new session with unique ID', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
expect(session.id).toBeDefined()
|
||||
expect(session.id.length).toBeGreaterThan(0)
|
||||
expect(session.phoneConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('should set correct timestamps on creation', () => {
|
||||
const now = new Date()
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
expect(session.createdAt.getTime()).toBe(now.getTime())
|
||||
expect(session.lastActivityAt.getTime()).toBe(now.getTime())
|
||||
// TTL should be 60 minutes
|
||||
expect(session.expiresAt.getTime()).toBe(now.getTime() + 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('should create multiple sessions with unique IDs', () => {
|
||||
const session1 = createRemoteCameraSession()
|
||||
const session2 = createRemoteCameraSession()
|
||||
|
||||
expect(session1.id).not.toBe(session2.id)
|
||||
expect(getSessionCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRemoteCameraSession', () => {
|
||||
it('should retrieve an existing session', () => {
|
||||
const created = createRemoteCameraSession()
|
||||
const retrieved = getRemoteCameraSession(created.id)
|
||||
|
||||
expect(retrieved).not.toBeNull()
|
||||
expect(retrieved?.id).toBe(created.id)
|
||||
})
|
||||
|
||||
it('should return null for non-existent session', () => {
|
||||
const session = getRemoteCameraSession('non-existent-id')
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for expired session', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
const sessionId = session.id
|
||||
|
||||
// Advance time past expiration (61 minutes)
|
||||
vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000))
|
||||
|
||||
const retrieved = getRemoteCameraSession(sessionId)
|
||||
expect(retrieved).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrCreateSession', () => {
|
||||
it('should create new session with provided ID if not exists', () => {
|
||||
const customId = 'my-custom-session-id'
|
||||
const session = getOrCreateSession(customId)
|
||||
|
||||
expect(session.id).toBe(customId)
|
||||
expect(session.phoneConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('should return existing session if not expired', () => {
|
||||
const customId = 'existing-session'
|
||||
const original = getOrCreateSession(customId)
|
||||
|
||||
// Mark phone connected to verify we get same session
|
||||
markPhoneConnected(customId)
|
||||
|
||||
const retrieved = getOrCreateSession(customId)
|
||||
|
||||
expect(retrieved.id).toBe(original.id)
|
||||
expect(retrieved.phoneConnected).toBe(true)
|
||||
})
|
||||
|
||||
it('should renew TTL when accessing existing session', () => {
|
||||
const now = new Date()
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const customId = 'session-to-renew'
|
||||
const original = getOrCreateSession(customId)
|
||||
const originalExpiry = original.expiresAt.getTime()
|
||||
|
||||
// Advance time by 30 minutes
|
||||
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
|
||||
|
||||
const retrieved = getOrCreateSession(customId)
|
||||
|
||||
// Expiry should be extended from current time
|
||||
expect(retrieved.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
|
||||
})
|
||||
|
||||
it('should create new session if existing one expired', () => {
|
||||
const customId = 'expired-session'
|
||||
const original = getOrCreateSession(customId)
|
||||
markPhoneConnected(customId) // Mark to distinguish
|
||||
|
||||
// Advance time past expiration
|
||||
vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000))
|
||||
|
||||
const newSession = getOrCreateSession(customId)
|
||||
|
||||
// Should be a fresh session (not phone connected)
|
||||
expect(newSession.id).toBe(customId)
|
||||
expect(newSession.phoneConnected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renewSessionTTL', () => {
|
||||
it('should extend session expiration time', () => {
|
||||
const now = new Date()
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const session = createRemoteCameraSession()
|
||||
const originalExpiry = session.expiresAt.getTime()
|
||||
|
||||
// Advance time by 30 minutes
|
||||
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
|
||||
|
||||
const renewed = renewSessionTTL(session.id)
|
||||
|
||||
expect(renewed).toBe(true)
|
||||
|
||||
const updatedSession = getRemoteCameraSession(session.id)
|
||||
expect(updatedSession?.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
|
||||
})
|
||||
|
||||
it('should update lastActivityAt', () => {
|
||||
const now = new Date()
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
// Advance time
|
||||
const later = new Date(now.getTime() + 10 * 60 * 1000)
|
||||
vi.setSystemTime(later)
|
||||
|
||||
renewSessionTTL(session.id)
|
||||
|
||||
const updatedSession = getRemoteCameraSession(session.id)
|
||||
expect(updatedSession?.lastActivityAt.getTime()).toBe(later.getTime())
|
||||
})
|
||||
|
||||
it('should return false for non-existent session', () => {
|
||||
const result = renewSessionTTL('non-existent')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calibration persistence', () => {
|
||||
const testCalibration = {
|
||||
corners: {
|
||||
topLeft: { x: 10, y: 10 },
|
||||
topRight: { x: 100, y: 10 },
|
||||
bottomLeft: { x: 10, y: 100 },
|
||||
bottomRight: { x: 100, y: 100 },
|
||||
},
|
||||
}
|
||||
|
||||
it('should store calibration data', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
const result = setSessionCalibration(session.id, testCalibration)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should retrieve calibration data', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
setSessionCalibration(session.id, testCalibration)
|
||||
|
||||
const retrieved = getSessionCalibration(session.id)
|
||||
|
||||
expect(retrieved).toEqual(testCalibration)
|
||||
})
|
||||
|
||||
it('should return null for session without calibration', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
const calibration = getSessionCalibration(session.id)
|
||||
|
||||
expect(calibration).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for non-existent session', () => {
|
||||
const calibration = getSessionCalibration('non-existent')
|
||||
expect(calibration).toBeNull()
|
||||
})
|
||||
|
||||
it('should renew TTL when setting calibration', () => {
|
||||
const now = new Date()
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const session = createRemoteCameraSession()
|
||||
const originalExpiry = session.expiresAt.getTime()
|
||||
|
||||
// Advance time
|
||||
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
|
||||
|
||||
setSessionCalibration(session.id, testCalibration)
|
||||
|
||||
const updatedSession = getRemoteCameraSession(session.id)
|
||||
expect(updatedSession?.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
|
||||
})
|
||||
|
||||
it('should persist calibration across session retrievals', () => {
|
||||
const customId = 'calibrated-session'
|
||||
const session = getOrCreateSession(customId)
|
||||
setSessionCalibration(session.id, testCalibration)
|
||||
|
||||
// Simulate reconnection by getting session again
|
||||
const reconnected = getOrCreateSession(customId)
|
||||
|
||||
expect(reconnected.calibration).toEqual(testCalibration)
|
||||
})
|
||||
})
|
||||
|
||||
describe('phone connection state', () => {
|
||||
it('should mark phone as connected', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
const result = markPhoneConnected(session.id)
|
||||
|
||||
expect(result).toBe(true)
|
||||
const updated = getRemoteCameraSession(session.id)
|
||||
expect(updated?.phoneConnected).toBe(true)
|
||||
})
|
||||
|
||||
it('should mark phone as disconnected', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
markPhoneConnected(session.id)
|
||||
|
||||
const result = markPhoneDisconnected(session.id)
|
||||
|
||||
expect(result).toBe(true)
|
||||
const updated = getRemoteCameraSession(session.id)
|
||||
expect(updated?.phoneConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('should extend TTL when phone connects', () => {
|
||||
const now = new Date()
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
// Advance time
|
||||
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
|
||||
|
||||
markPhoneConnected(session.id)
|
||||
|
||||
const updated = getRemoteCameraSession(session.id)
|
||||
// Expiry should be 60 mins from now (not from creation)
|
||||
expect(updated?.expiresAt.getTime()).toBeGreaterThan(now.getTime() + 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('should return false for non-existent session', () => {
|
||||
expect(markPhoneConnected('non-existent')).toBe(false)
|
||||
expect(markPhoneDisconnected('non-existent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteRemoteCameraSession', () => {
|
||||
it('should delete existing session', () => {
|
||||
const session = createRemoteCameraSession()
|
||||
|
||||
const result = deleteRemoteCameraSession(session.id)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(getRemoteCameraSession(session.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('should return false for non-existent session', () => {
|
||||
const result = deleteRemoteCameraSession('non-existent')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session count', () => {
|
||||
it('should track total sessions', () => {
|
||||
expect(getSessionCount()).toBe(0)
|
||||
|
||||
createRemoteCameraSession()
|
||||
expect(getSessionCount()).toBe(1)
|
||||
|
||||
createRemoteCameraSession()
|
||||
expect(getSessionCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
* Remote Camera Session Manager
|
||||
*
|
||||
* Manages in-memory sessions for phone-to-desktop camera streaming.
|
||||
* Sessions are short-lived (10 minute TTL) and stored in memory.
|
||||
* Sessions have a 60-minute TTL but are renewed on activity.
|
||||
* Sessions persist across page reloads via session ID stored client-side.
|
||||
*/
|
||||
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
|
|
@ -11,7 +12,12 @@ export interface RemoteCameraSession {
|
|||
id: string
|
||||
createdAt: Date
|
||||
expiresAt: Date
|
||||
lastActivityAt: Date
|
||||
phoneConnected: boolean
|
||||
/** Calibration data sent from desktop (persists for reconnects) */
|
||||
calibration?: {
|
||||
corners: { topLeft: { x: number; y: number }; topRight: { x: number; y: number }; bottomLeft: { x: number; y: number }; bottomRight: { x: number; y: number } }
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory session storage
|
||||
|
|
@ -21,7 +27,7 @@ declare global {
|
|||
var __remoteCameraSessions: Map<string, RemoteCameraSession> | undefined
|
||||
}
|
||||
|
||||
const SESSION_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000 // 60 minutes
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000 // 1 minute
|
||||
|
||||
function getSessions(): Map<string, RemoteCameraSession> {
|
||||
|
|
@ -44,6 +50,7 @@ export function createRemoteCameraSession(): RemoteCameraSession {
|
|||
id: createId(),
|
||||
createdAt: now,
|
||||
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
|
||||
lastActivityAt: now,
|
||||
phoneConnected: false,
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +58,82 @@ export function createRemoteCameraSession(): RemoteCameraSession {
|
|||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a session by ID
|
||||
* If the session exists and isn't expired, returns it (renewed)
|
||||
* If the session doesn't exist, creates a new one with the given ID
|
||||
*/
|
||||
export function getOrCreateSession(sessionId: string): RemoteCameraSession {
|
||||
const sessions = getSessions()
|
||||
const existing = sessions.get(sessionId)
|
||||
const now = new Date()
|
||||
|
||||
if (existing && now <= existing.expiresAt) {
|
||||
// Renew TTL on access
|
||||
existing.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
|
||||
existing.lastActivityAt = now
|
||||
return existing
|
||||
}
|
||||
|
||||
// Create new session with provided ID
|
||||
const session: RemoteCameraSession = {
|
||||
id: sessionId,
|
||||
createdAt: now,
|
||||
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
|
||||
lastActivityAt: now,
|
||||
phoneConnected: false,
|
||||
}
|
||||
|
||||
sessions.set(session.id, session)
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew session TTL (call on activity to keep session alive)
|
||||
*/
|
||||
export function renewSessionTTL(sessionId: string): boolean {
|
||||
const sessions = getSessions()
|
||||
const session = sessions.get(sessionId)
|
||||
|
||||
if (!session) return false
|
||||
|
||||
const now = new Date()
|
||||
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
|
||||
session.lastActivityAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Store calibration data in session (persists for reconnects)
|
||||
*/
|
||||
export function setSessionCalibration(
|
||||
sessionId: string,
|
||||
calibration: RemoteCameraSession['calibration']
|
||||
): boolean {
|
||||
const sessions = getSessions()
|
||||
const session = sessions.get(sessionId)
|
||||
|
||||
if (!session) return false
|
||||
|
||||
session.calibration = calibration
|
||||
// Also renew TTL
|
||||
const now = new Date()
|
||||
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
|
||||
session.lastActivityAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calibration data from session
|
||||
*/
|
||||
export function getSessionCalibration(sessionId: string): RemoteCameraSession['calibration'] | null {
|
||||
const sessions = getSessions()
|
||||
const session = sessions.get(sessionId)
|
||||
|
||||
if (!session) return null
|
||||
return session.calibration || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by ID
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@ 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
|
||||
// Guard for node environment where HTMLImageElement doesn't exist
|
||||
if (typeof HTMLImageElement !== 'undefined') {
|
||||
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)
|
||||
}
|
||||
return originalSetAttribute.call(this, name, value)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue