generated from CubeCraft-Creations/Tracehound
CUB-176: central hub frontend — camera grid, start/stop controls, history viewer
- CameraCard: color-coded status (green/yellow/red), per-camera start/stop, battery bar, recording indicator - HistoryViewer: modal dialog with 24h status log browsing per camera - App: responsive grid (1-4 cols), Start/Stop All global buttons, SSE connection badge, live stats strip - API service: aligned with backend endpoints (list, detail, start, stop) - Types: added StatusLog, CameraDetail, CameraInfo, StartStopResponse - All 23 tests pass, lint clean, TypeScript + Vite build clean
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import CameraCard from './CameraCard'
|
||||
import type { CameraStatus } from '../types'
|
||||
|
||||
@@ -19,52 +19,52 @@ function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
|
||||
}
|
||||
}
|
||||
|
||||
const noop = vi.fn()
|
||||
const renderCard = (overrides?: Partial<CameraStatus>) =>
|
||||
render(<CameraCard camera={makeCamera(overrides ?? {})} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||
const renderCardContainer = (camera: CameraStatus) =>
|
||||
render(<CameraCard camera={camera} onStart={noop} onStop={noop} onViewHistory={noop} />)
|
||||
|
||||
describe('CameraCard', () => {
|
||||
// ── Basic rendering ────────────────────────────────────────────────────
|
||||
|
||||
it('renders camera name', () => {
|
||||
render(<CameraCard camera={makeCamera()} />)
|
||||
renderCard()
|
||||
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows resolution and FPS', () => {
|
||||
render(<CameraCard camera={makeCamera()} />)
|
||||
renderCard()
|
||||
expect(screen.getByText(/1080p/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/30\s*FPS/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows battery percentage', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
|
||||
renderCard({ battery_pct: 85 })
|
||||
expect(screen.getByText('85%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows N/A when battery is null', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
|
||||
renderCard({ battery_pct: null })
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Battery bar colors ─────────────────────────────────────────────────
|
||||
|
||||
it('uses green bar for high battery (>=50%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 85 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-success')
|
||||
})
|
||||
|
||||
it('uses yellow bar for medium battery (15-49%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 30 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-warning')
|
||||
})
|
||||
|
||||
it('uses red bar for low battery (<15%)', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 8 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-danger')
|
||||
})
|
||||
@@ -72,24 +72,24 @@ describe('CameraCard', () => {
|
||||
// ── Recording state ────────────────────────────────────────────────────
|
||||
|
||||
it('shows REC badge when recording', () => {
|
||||
render(<CameraCard camera={makeCamera({ recording: true })} />)
|
||||
renderCard({ recording: true })
|
||||
expect(screen.getByText('REC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows IDLE badge when not recording', () => {
|
||||
render(<CameraCard camera={makeCamera({ recording: false })} />)
|
||||
renderCard({ recording: false })
|
||||
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Online / Offline badges ────────────────────────────────────────────
|
||||
|
||||
it('shows Online badge when camera is online', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||
renderCard({ online: true })
|
||||
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Offline badge when camera is offline', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
||||
renderCard({ online: false })
|
||||
const offlineElements = screen.getAllByText('Offline')
|
||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
@@ -97,13 +97,13 @@ describe('CameraCard', () => {
|
||||
// ── Video remaining ────────────────────────────────────────────────────
|
||||
|
||||
it('shows video remaining time when available', () => {
|
||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />)
|
||||
renderCard({ video_remaining_sec: 125 })
|
||||
// formatTimeLeft(125) → "2m 5s left"
|
||||
expect(screen.getByText(/2m 5s left/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show video remaining when null', () => {
|
||||
render(<CameraCard camera={makeCamera({ video_remaining_sec: null })} />)
|
||||
renderCard({ video_remaining_sec: null })
|
||||
// The Radio icon and time text should not be present
|
||||
expect(screen.queryByText(/m\s+\d+s left/)).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -111,53 +111,67 @@ describe('CameraCard', () => {
|
||||
// ── Footer ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows Live + timestamp in footer when online', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||
// Footer shows "Live" when online
|
||||
renderCard({ online: true })
|
||||
expect(screen.getByText('Live')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Offline + timestamp in footer when offline', () => {
|
||||
render(<CameraCard camera={makeCamera({ online: false })} />)
|
||||
// Footer says "Offline" (the text appears both in the badge and footer)
|
||||
// When offline, the footer specifically shows "Offline" text
|
||||
it('shows Offline in footer when offline', () => {
|
||||
renderCard({ online: false })
|
||||
const offlineElements = screen.getAllByText('Offline')
|
||||
// At least one should exist (badge + footer)
|
||||
expect(offlineElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows "unknown" when last_seen is malformed', () => {
|
||||
render(
|
||||
<CameraCard camera={makeCamera({ last_seen: 'not-a-date' })} />,
|
||||
)
|
||||
renderCard({ last_seen: 'not-a-date' })
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "unknown" when last_seen is in the future', () => {
|
||||
const future = new Date(Date.now() + 86400000).toISOString() // +1 day
|
||||
render(<CameraCard camera={makeCamera({ last_seen: future })} />)
|
||||
const cam = makeCamera({ last_seen: future })
|
||||
renderCardContainer(cam)
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────────────────
|
||||
|
||||
it('clamps negative battery_pct to 0%', () => {
|
||||
render(<CameraCard camera={makeCamera({ battery_pct: -5 })} />)
|
||||
renderCard({ battery_pct: -5 })
|
||||
expect(screen.getByText('0%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows exact boundary: 15% battery → yellow bar', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 15 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 15 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-warning')
|
||||
})
|
||||
|
||||
it('shows exact boundary: 50% battery → green bar', () => {
|
||||
const { container } = render(
|
||||
<CameraCard camera={makeCamera({ battery_pct: 50 })} />,
|
||||
)
|
||||
const { container } = renderCard({ battery_pct: 50 })
|
||||
const bar = container.querySelector('[role="progressbar"] div')
|
||||
expect(bar?.className).toContain('bg-rig-success')
|
||||
})
|
||||
|
||||
// ── New prop-driven tests ──────────────────────────────────────────────
|
||||
|
||||
it('calls onStart when Record button is clicked', () => {
|
||||
const onStart = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({ recording: false })} onStart={onStart} onStop={noop} onViewHistory={noop} />)
|
||||
screen.getByText('Record').click()
|
||||
expect(onStart).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
|
||||
it('calls onStop when Stop button is clicked', () => {
|
||||
const onStop = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({ recording: true })} onStart={noop} onStop={onStop} onViewHistory={noop} />)
|
||||
screen.getByText('Stop').click()
|
||||
expect(onStop).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
|
||||
it('calls onViewHistory when History button is clicked', () => {
|
||||
const onViewHistory = vi.fn()
|
||||
render(<CameraCard camera={makeCamera({})} onStart={noop} onStop={noop} onViewHistory={onViewHistory} />)
|
||||
screen.getByText('History').click()
|
||||
expect(onViewHistory).toHaveBeenCalledWith('cam-1')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user