generated from CubeCraft-Creations/Tracehound
CUB-196: add accessibility and edge case fixes for CameraCard
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
name: Build (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.23"
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
BINARY_NAME: openclaw
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Build React frontend
|
||||||
|
working-directory: web
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Embed frontend into Go binary
|
||||||
|
run: |
|
||||||
|
mkdir -p internal/web/dist
|
||||||
|
cp -r web/dist/* internal/web/dist/
|
||||||
|
go generate ./internal/web/...
|
||||||
|
|
||||||
|
- name: Build Go binary
|
||||||
|
run: |
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
|
||||||
|
-o ${{ env.BINARY_NAME }} ./cmd/server
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.BINARY_NAME }}
|
||||||
|
path: ${{ env.BINARY_NAME }}
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Trigger deploy workflow
|
||||||
|
if: success()
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
await github.rest.repos.createDispatchEvent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
event_type: 'dev-build-success',
|
||||||
|
client_payload: {
|
||||||
|
sha: context.sha,
|
||||||
|
ref: context.ref
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: Deploy (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
repository_dispatch:
|
||||||
|
types:
|
||||||
|
- dev-build-success
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
BINARY_NAME: openclaw
|
||||||
|
DEV_HOST: ${{ secrets.DEV_HOST }}
|
||||||
|
DEV_USER: ${{ secrets.DEV_USER }}
|
||||||
|
DEPLOY_PATH: /opt/openclaw/openclaw
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.BINARY_NAME }}
|
||||||
|
|
||||||
|
- name: Ensure binary is executable
|
||||||
|
run: chmod +x ${{ env.BINARY_NAME }}
|
||||||
|
|
||||||
|
- name: Write deploy script
|
||||||
|
run: |
|
||||||
|
cat > deploy.sh <<'SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
BINARY="${1:-openclaw}"
|
||||||
|
DEPLOY_PATH="${2:-/opt/openclaw/openclaw}"
|
||||||
|
SERVICE="${3:-openclaw}"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||||
|
BACKUP="${DEPLOY_PATH}.${TIMESTAMP}.bak"
|
||||||
|
|
||||||
|
echo "::backup:: copying current binary"
|
||||||
|
if [ -f "$DEPLOY_PATH" ]; then
|
||||||
|
cp "$DEPLOY_PATH" "$BACKUP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::deploy:: installing new binary"
|
||||||
|
cp "$BINARY" "$DEPLOY_PATH"
|
||||||
|
chmod +x "$DEPLOY_PATH"
|
||||||
|
|
||||||
|
echo "::restart:: reloading service"
|
||||||
|
systemctl reload-or-restart "$SERVICE" || systemctl restart "$SERVICE"
|
||||||
|
|
||||||
|
echo "::health:: waiting for service"
|
||||||
|
sleep 3
|
||||||
|
if systemctl is-active --quiet "$SERVICE"; then
|
||||||
|
echo "deploy ok — ${SERVICE} is active"
|
||||||
|
else
|
||||||
|
echo "::rollback:: service failed, restoring backup"
|
||||||
|
if [ -f "$BACKUP" ]; then
|
||||||
|
cp "$BACKUP" "$DEPLOY_PATH"
|
||||||
|
systemctl restart "$SERVICE"
|
||||||
|
fi
|
||||||
|
echo "rolled back to previous binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::cleanup:: removing old backups (keeping last 3)"
|
||||||
|
ls -t "${DEPLOY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||||
|
SCRIPT
|
||||||
|
chmod +x deploy.sh
|
||||||
|
|
||||||
|
- name: Deploy to dev server
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ env.DEV_HOST }}
|
||||||
|
username: ${{ env.DEV_USER }}
|
||||||
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
|
source: "${{ env.BINARY_NAME }},deploy.sh"
|
||||||
|
target: "/tmp/openclaw-deploy"
|
||||||
|
|
||||||
|
- name: Execute deploy on dev server
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ env.DEV_HOST }}
|
||||||
|
username: ${{ env.DEV_USER }}
|
||||||
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd /tmp/openclaw-deploy
|
||||||
|
sudo ./deploy.sh "${{ env.BINARY_NAME }}" "${{ env.DEPLOY_PATH }}" "openclaw"
|
||||||
|
rm -rf /tmp/openclaw-deploy
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ env.DEV_HOST }}
|
||||||
|
username: ${{ env.DEV_USER }}
|
||||||
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
echo "deploy failed for commit ${{ github.sha }} on ${{ github.repository }}" > /tmp/openclaw-deploy-failure.txt
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
# CUB-196: CameraCard Component Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Build the CameraCard React component that displays one camera's live status with color-coded battery, pulsing recording indicator, and online/offline state.
|
||||||
|
|
||||||
|
**Architecture:** Single component at `src/components/CameraCard.tsx` consuming `CameraStatus` from the existing Zustand store. Uses Tailwind for styling (existing dark dashboard palette), lucide-react for icons. No new dependencies needed — everything is already in `package.json`.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TypeScript 5.7, Tailwind CSS 3.4, Zustand 5, lucide-react 0.469
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before any task, ensure the repo is on `dev` and clean:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/ai-storage/projects/remote-rig
|
||||||
|
git checkout dev
|
||||||
|
git pull origin dev
|
||||||
|
git checkout -b agent/<your-agent-name>/CUB-196-cameracard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create the CameraCard component
|
||||||
|
|
||||||
|
**Objective:** Build `src/components/CameraCard.tsx` with all required display elements.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/CameraCard.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create the component file**
|
||||||
|
|
||||||
|
Create `src/components/CameraCard.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Battery, Radio, Signal, Video, Wifi, WifiOff } from 'lucide-react'
|
||||||
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
|
interface CameraCardProps {
|
||||||
|
camera: CameraStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format ISO timestamp as relative time (e.g. "2m ago", "1h ago") */
|
||||||
|
function formatRelativeTime(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const sec = Math.floor(diff / 1000)
|
||||||
|
if (sec < 60) return 'just now'
|
||||||
|
const min = Math.floor(sec / 60)
|
||||||
|
if (min < 60) return `${min}m ago`
|
||||||
|
const hours = Math.floor(min / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return Tailwind color classes for battery level */
|
||||||
|
function batteryColor(pct: number | null): { bar: string; text: string } {
|
||||||
|
if (pct === null) return { bar: 'bg-rig-dark-500', text: 'text-rig-dark-300' }
|
||||||
|
if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
|
||||||
|
if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
|
||||||
|
return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CameraCard({ camera }: CameraCardProps) {
|
||||||
|
const battery = batteryColor(camera.battery_pct)
|
||||||
|
const online = camera.online
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
rounded-xl border bg-rig-dark-800/60 p-5 transition-all
|
||||||
|
${online
|
||||||
|
? 'border-rig-dark-700 hover:border-rig-dark-600'
|
||||||
|
: 'border-rig-danger/30 opacity-70'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header: camera name + online indicator */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Video className="h-5 w-5 shrink-0 text-rig-accent" />
|
||||||
|
<h3 className="truncate text-base font-semibold text-rig-dark-100">
|
||||||
|
{camera.friendly_name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
|
||||||
|
${online
|
||||||
|
? 'bg-rig-success/10 text-rig-success'
|
||||||
|
: 'bg-rig-danger/10 text-rig-danger'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{online ? (
|
||||||
|
<Wifi className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body: resolution + fps, recording indicator */}
|
||||||
|
<div className="mb-4 space-y-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-rig-dark-300">
|
||||||
|
<Signal className="h-4 w-4 text-rig-dark-400" />
|
||||||
|
<span>{camera.resolution}</span>
|
||||||
|
<span className="text-rig-dark-500">·</span>
|
||||||
|
<span>{camera.fps} FPS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
|
||||||
|
${camera.recording
|
||||||
|
? 'bg-rig-danger/15 text-rig-danger'
|
||||||
|
: 'bg-rig-dark-600/50 text-rig-dark-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-block h-2 w-2 rounded-full
|
||||||
|
${camera.recording ? 'animate-pulse bg-rig-danger' : 'bg-rig-dark-500'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
{camera.recording ? 'REC' : 'IDLE'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-rig-dark-500">{camera.mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Battery bar */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
|
||||||
|
<Battery className="h-3.5 w-3.5" />
|
||||||
|
<span>Battery</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium ${battery.text}`}>
|
||||||
|
{camera.battery_pct !== null ? `${camera.battery_pct}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${battery.bar}`}
|
||||||
|
style={{
|
||||||
|
width: `${camera.battery_pct !== null ? Math.max(camera.battery_pct, 4) : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: last seen + video remaining */}
|
||||||
|
<div className="flex items-center justify-between border-t border-rig-dark-700 pt-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
|
||||||
|
<Radio className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{camera.online ? 'Live' : `Last seen ${formatRelativeTime(camera.last_seen)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{camera.video_remaining_sec !== null && (
|
||||||
|
<span className="text-xs font-mono text-rig-dark-300">
|
||||||
|
{Math.floor(camera.video_remaining_sec / 60)}m {camera.video_remaining_sec % 60}s left
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create barrel export**
|
||||||
|
|
||||||
|
Create `src/components/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { CameraCard } from './CameraCard'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Type-check**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/
|
||||||
|
git commit -m "CUB-196: add CameraCard component with battery bar, recording indicator, online status"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Wire CameraCard into App.tsx
|
||||||
|
|
||||||
|
**Objective:** Replace the placeholder with a live camera grid using the SSE-backed store.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/App.tsx`
|
||||||
|
|
||||||
|
**Step 1: Update App.tsx**
|
||||||
|
|
||||||
|
Replace `src/App.tsx` content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Camera, Radio } from 'lucide-react'
|
||||||
|
import { useSSE } from './hooks/useSSE'
|
||||||
|
import { useCameraStore } from './store/useCameraStore'
|
||||||
|
import { CameraCard } from './components/CameraCard'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
useSSE()
|
||||||
|
|
||||||
|
const cameras = useCameraStore((s) => s.getCameras())
|
||||||
|
const onlineCount = useCameraStore((s) => s.getOnlineCount())
|
||||||
|
const recordingCount = useCameraStore((s) => s.getRecordingCount())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-rig-dark-900">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Camera className="h-7 w-7 text-rig-accent" />
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
|
||||||
|
RemoteRig
|
||||||
|
</h1>
|
||||||
|
<span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
|
||||||
|
Dashboard
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{cameras.length > 0 && (
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="flex items-center gap-1.5 text-rig-dark-300">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-rig-success" />
|
||||||
|
{onlineCount} online
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-rig-dark-300">
|
||||||
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-rig-danger" />
|
||||||
|
{recordingCount} recording
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
{cameras.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
|
||||||
|
<Radio className="mb-4 h-12 w-12 animate-pulse text-rig-dark-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-rig-dark-200">
|
||||||
|
Waiting for cameras...
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-rig-dark-400">
|
||||||
|
Connect a camera or start the hub backend to see live status here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{cameras.map((cam) => (
|
||||||
|
<CameraCard key={cam.camera_id} camera={cam} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<p className="text-center text-xs text-rig-dark-500">
|
||||||
|
RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Type-check and build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: TypeScript compiles clean, Vite build succeeds.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/App.tsx
|
||||||
|
git commit -m "CUB-196: wire CameraCard into App grid with SSE live updates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add unit tests for CameraCard
|
||||||
|
|
||||||
|
**Objective:** Install vitest + testing-library and write tests for battery colors, recording indicator, and online/offline state.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/CameraCard.test.tsx`
|
||||||
|
- Modify: `package.json` (add test script)
|
||||||
|
|
||||||
|
**Step 1: Install test dependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add test config**
|
||||||
|
|
||||||
|
Add to `vite.config.ts` the `test` block (reference: https://vitest.dev/config/):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/// <reference types="vitest/config" />
|
||||||
|
// ... existing imports and config, add:
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: [],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Create setup file**
|
||||||
|
|
||||||
|
Create `src/test-setup.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
```
|
||||||
|
|
||||||
|
Update vite.config.ts `setupFiles` to `['./src/test-setup.ts']`.
|
||||||
|
|
||||||
|
**Step 4: Add test script**
|
||||||
|
|
||||||
|
Update `package.json` scripts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Write CameraCard tests**
|
||||||
|
|
||||||
|
Create `src/components/CameraCard.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { CameraCard } from './CameraCard'
|
||||||
|
import type { CameraStatus } from '../types'
|
||||||
|
|
||||||
|
function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
|
||||||
|
return {
|
||||||
|
camera_id: 'cam-1',
|
||||||
|
friendly_name: 'Front Camera',
|
||||||
|
battery_pct: 85,
|
||||||
|
video_remaining_sec: 3600,
|
||||||
|
recording: false,
|
||||||
|
mode: 'video',
|
||||||
|
resolution: '1080p',
|
||||||
|
fps: 30,
|
||||||
|
online: true,
|
||||||
|
last_seen: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CameraCard', () => {
|
||||||
|
it('renders camera name', () => {
|
||||||
|
render(<CameraCard camera={makeCamera()} />)
|
||||||
|
expect(screen.getByText('Front Camera')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows resolution and FPS', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ resolution: '4K', fps: 60 })} />)
|
||||||
|
expect(screen.getByText('4K')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('60 FPS')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows battery percentage', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
|
||||||
|
expect(screen.getByText('85%')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows N/A when battery is null', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
|
||||||
|
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses green for high battery (>=50%)', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CameraCard camera={makeCamera({ battery_pct: 85 })} />,
|
||||||
|
)
|
||||||
|
// The battery bar inner div should have bg-rig-success
|
||||||
|
const bar = container.querySelector('.h-1\\.5 > div')
|
||||||
|
expect(bar?.className).toContain('bg-rig-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses yellow for medium battery (15-49%)', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CameraCard camera={makeCamera({ battery_pct: 30 })} />,
|
||||||
|
)
|
||||||
|
const bar = container.querySelector('.h-1\\.5 > div')
|
||||||
|
expect(bar?.className).toContain('bg-rig-warning')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses red for low battery (<15%)', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CameraCard camera={makeCamera({ battery_pct: 8 })} />,
|
||||||
|
)
|
||||||
|
const bar = container.querySelector('.h-1\\.5 > div')
|
||||||
|
expect(bar?.className).toContain('bg-rig-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows REC badge with pulsing dot when recording', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ recording: true })} />)
|
||||||
|
expect(screen.getByText('REC')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows IDLE badge when not recording', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ recording: false })} />)
|
||||||
|
expect(screen.getByText('IDLE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Online badge when camera is online', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||||
|
expect(screen.getByText('Online')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Offline badge when camera is offline', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ online: false })} />)
|
||||||
|
expect(screen.getByText('Offline')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows video remaining time when available', () => {
|
||||||
|
render(
|
||||||
|
<CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('2m 5s left')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show video remaining when null', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CameraCard camera={makeCamera({ video_remaining_sec: null })} />,
|
||||||
|
)
|
||||||
|
expect(container.textContent).not.toContain('left')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Live in footer when online', () => {
|
||||||
|
render(<CameraCard camera={makeCamera({ online: true })} />)
|
||||||
|
expect(screen.getByText('Live')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows relative time in footer when offline', () => {
|
||||||
|
render(
|
||||||
|
<CameraCard
|
||||||
|
camera={makeCamera({
|
||||||
|
online: false,
|
||||||
|
last_seen: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/5m ago/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All 15 tests pass.
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/CameraCard.test.tsx src/test-setup.ts vite.config.ts package.json
|
||||||
|
git commit -m "CUB-196: add CameraCard unit tests (vitest + testing-library)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After all tasks complete:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Type-check
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
All must pass before pushing.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -4,9 +4,10 @@ import type { CameraStatus } from '../types'
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatRelativeTime(iso: string): string {
|
function formatRelativeTime(iso: string): string {
|
||||||
const now = Date.now()
|
|
||||||
const then = new Date(iso).getTime()
|
const then = new Date(iso).getTime()
|
||||||
const diffSec = Math.floor((now - then) / 1000)
|
if (isNaN(then)) return 'unknown'
|
||||||
|
|
||||||
|
const diffSec = Math.floor((Date.now() - then) / 1000)
|
||||||
|
|
||||||
if (diffSec < 10) return 'just now'
|
if (diffSec < 10) return 'just now'
|
||||||
if (diffSec < 60) return `${diffSec}s ago`
|
if (diffSec < 60) return `${diffSec}s ago`
|
||||||
@@ -29,6 +30,7 @@ function batteryColor(pct: number | null): { bar: string; text: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeLeft(sec: number): string {
|
function formatTimeLeft(sec: number): string {
|
||||||
|
if (sec <= 0 || !isFinite(sec)) return '--'
|
||||||
const m = Math.floor(sec / 60)
|
const m = Math.floor(sec / 60)
|
||||||
const s = sec % 60
|
const s = sec % 60
|
||||||
return `${m}m ${s}s left`
|
return `${m}m ${s}s left`
|
||||||
@@ -56,7 +58,7 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
const batt = batteryColor(battery_pct)
|
const batt = batteryColor(battery_pct)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
className={`rounded-xl border bg-rig-dark-800/60 transition-colors ${
|
||||||
online
|
online
|
||||||
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
? 'border-rig-dark-600 hover:border-rig-accent/40'
|
||||||
@@ -66,14 +68,19 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Video className="h-4 w-4 text-rig-accent" />
|
<Video className="h-4 w-4 text-rig-accent" aria-hidden="true" />
|
||||||
<h3 className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]">
|
<h3
|
||||||
|
className="text-sm font-semibold text-rig-dark-100 truncate max-w-[180px]"
|
||||||
|
title={friendly_name}
|
||||||
|
>
|
||||||
{friendly_name}
|
{friendly_name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Online / Offline badge */}
|
{/* Online / Offline badge */}
|
||||||
<span
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={online ? 'Camera online' : 'Camera offline'}
|
||||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
online
|
online
|
||||||
? 'bg-rig-success/15 text-rig-success'
|
? 'bg-rig-success/15 text-rig-success'
|
||||||
@@ -81,9 +88,9 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{online ? (
|
{online ? (
|
||||||
<Wifi className="h-3 w-3" />
|
<Wifi className="h-3 w-3" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<WifiOff className="h-3 w-3" />
|
<WifiOff className="h-3 w-3" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
{online ? 'Online' : 'Offline'}
|
{online ? 'Online' : 'Offline'}
|
||||||
</span>
|
</span>
|
||||||
@@ -103,7 +110,11 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{recording ? (
|
{recording ? (
|
||||||
<>
|
<>
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
<span
|
||||||
|
className="relative flex h-2.5 w-2.5"
|
||||||
|
role="status"
|
||||||
|
aria-label="Recording in progress"
|
||||||
|
>
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rig-danger opacity-75" />
|
||||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-rig-danger" />
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-rig-danger" />
|
||||||
</span>
|
</span>
|
||||||
@@ -130,7 +141,14 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
{battery_pct !== null ? `${battery_pct}%` : 'N/A'}
|
{battery_pct !== null ? `${battery_pct}%` : 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700">
|
<div
|
||||||
|
className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={battery_pct ?? 0}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`Battery ${battery_pct !== null ? battery_pct + '%' : 'unknown'}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all ${batt.bar}`}
|
className={`h-full rounded-full transition-all ${batt.bar}`}
|
||||||
style={{ width: battery_pct !== null ? `${Math.min(100, Math.max(0, battery_pct))}%` : '0%' }}
|
style={{ width: battery_pct !== null ? `${Math.min(100, Math.max(0, battery_pct))}%` : '0%' }}
|
||||||
@@ -148,8 +166,10 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
<span className="text-rig-success">Live</span>
|
<span className="text-rig-success">Live</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
|
<span className="text-rig-dark-400">Offline</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-rig-dark-500">·</span>
|
||||||
|
<span className="text-rig-dark-400">{formatRelativeTime(last_seen)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{video_remaining_sec !== null && (
|
{video_remaining_sec !== null && (
|
||||||
@@ -159,6 +179,6 @@ export default function CameraCard({ camera }: CameraCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user