feat: /judge review + scoring + leaderboard — TDD
Final screen — replaces the last WorkshopStub; App.tsx now has zero stubs: - SubmissionList: live review queue with scored markers + selection - AddReview: read-only render of a submitted 5-layer ADD - ScoreForm: 0-10 rubric per layer, auto-summed total, notes - Leaderboard: ranked teams by average judge total - Judge page (behind name+code AccessGate): queue + review/score + leaderboard, refreshes on scoring and on any collective event 21 new tests; suite 140/140 green, typecheck + lint clean, build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6c07788fab
commit
bd3250c9c7
+2
-17
@@ -7,23 +7,8 @@ import { Module1 } from '@/pages/Module1'
|
|||||||
import { Module2 } from '@/pages/Module2'
|
import { Module2 } from '@/pages/Module2'
|
||||||
import { AddBuilder } from '@/pages/AddBuilder'
|
import { AddBuilder } from '@/pages/AddBuilder'
|
||||||
import { Admin } from '@/pages/Admin'
|
import { Admin } from '@/pages/Admin'
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
import { Judge } from '@/pages/Judge'
|
||||||
import { useCollectiveSync } from '@/lib/useCollectiveSync'
|
import { useCollectiveSync } from '@/lib/useCollectiveSync'
|
||||||
import type { PhaseKey } from '@/store/session'
|
|
||||||
|
|
||||||
function WorkshopStub({ title, phase }: { title: string; phase: PhaseKey }) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-background">
|
|
||||||
<PhaseStrip active={phase} />
|
|
||||||
<section className="px-8 py-16 max-w-3xl mx-auto">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-3">
|
|
||||||
Screen under construction — see PRD §5.1 for the full spec. Tests-first build in progress.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useCollectiveSync()
|
useCollectiveSync()
|
||||||
@@ -38,7 +23,7 @@ export default function App() {
|
|||||||
<Route path="/workshop/add" element={<AddBuilder />} />
|
<Route path="/workshop/add" element={<AddBuilder />} />
|
||||||
<Route path="/lecture" element={<Lecture />} />
|
<Route path="/lecture" element={<Lecture />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/judge" element={<WorkshopStub title="Judge review" phase="reg" />} />
|
<Route path="/judge" element={<Judge />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { AddReview } from './AddReview'
|
||||||
|
import type { SubmissionDTO } from '@/types'
|
||||||
|
|
||||||
|
const sub: SubmissionDTO = {
|
||||||
|
teamId: 't1',
|
||||||
|
teamName: 'team_resonance',
|
||||||
|
code: 'KIT-03-ABC',
|
||||||
|
add: { L1: { goal: 'stay safe' }, L2: 'reason', L3: 'act', L4: 'fail', L5: 'redesign' },
|
||||||
|
submittedAt: '2026-07-27T18:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AddReview', () => {
|
||||||
|
it('prompts when nothing is selected', () => {
|
||||||
|
render(<AddReview submission={null} />)
|
||||||
|
expect(screen.getByText(/select a submission/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all five layers of the submitted ADD', () => {
|
||||||
|
render(<AddReview submission={sub} />)
|
||||||
|
const review = screen.getByTestId('add-review')
|
||||||
|
expect(review).toHaveTextContent('stay safe')
|
||||||
|
expect(review).toHaveTextContent('reason')
|
||||||
|
expect(review).toHaveTextContent('act')
|
||||||
|
expect(review).toHaveTextContent('fail')
|
||||||
|
expect(review).toHaveTextContent('redesign')
|
||||||
|
expect(review).toHaveTextContent('KIT-03-ABC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a null L1 without crashing', () => {
|
||||||
|
render(<AddReview submission={{ ...sub, add: { ...sub.add, L1: null } }} />)
|
||||||
|
expect(screen.getByTestId('add-review')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { SubmissionDTO } from '@/types'
|
||||||
|
|
||||||
|
const LAYERS: { key: 'L2' | 'L3' | 'L4' | 'L5'; title: string }[] = [
|
||||||
|
{ key: 'L2', title: 'Reasoning policy' },
|
||||||
|
{ key: 'L3', title: 'Action contract' },
|
||||||
|
{ key: 'L4', title: 'Failure modes' },
|
||||||
|
{ key: 'L5', title: 'AI-native redesign' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Block({ n, title, body }: { n: number; title: string; body: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">Layer {n} · {title}</div>
|
||||||
|
<div className="text-sm leading-relaxed whitespace-pre-wrap">{body || <em className="text-muted-foreground">—</em>}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddReviewProps {
|
||||||
|
submission: SubmissionDTO | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only render of a submitted ADD for the judge. */
|
||||||
|
export function AddReview({ submission }: AddReviewProps) {
|
||||||
|
if (!submission) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Select a submission to review.</p>
|
||||||
|
}
|
||||||
|
const l1 = (submission.add.L1 as Record<string, string> | null) ?? {}
|
||||||
|
return (
|
||||||
|
<article data-testid="add-review" className="space-y-4">
|
||||||
|
<header>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div>
|
||||||
|
<h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3>
|
||||||
|
</header>
|
||||||
|
<Block
|
||||||
|
n={1}
|
||||||
|
title="Perception & goal"
|
||||||
|
body={Object.entries(l1)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join('\n')}
|
||||||
|
/>
|
||||||
|
{LAYERS.map((l, i) => (
|
||||||
|
<Block key={l.key} n={i + 2} title={l.title} body={submission.add[l.key]} />
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Leaderboard } from './Leaderboard'
|
||||||
|
import type { LeaderboardRow } from '@/types'
|
||||||
|
|
||||||
|
describe('Leaderboard', () => {
|
||||||
|
it('shows an empty state with no rows', () => {
|
||||||
|
render(<Leaderboard rows={[]} />)
|
||||||
|
expect(screen.getByText(/no scores yet/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders rows in the given order with avg + count', () => {
|
||||||
|
const rows: LeaderboardRow[] = [
|
||||||
|
{ teamId: 'b', teamName: 'Bravo', avgTotal: 9, scoreCount: 1 },
|
||||||
|
{ teamId: 'a', teamName: 'Alpha', avgTotal: 8, scoreCount: 2 },
|
||||||
|
]
|
||||||
|
render(<Leaderboard rows={rows} />)
|
||||||
|
const lis = screen.getByTestId('leaderboard').querySelectorAll('li')
|
||||||
|
expect(lis[0]).toHaveTextContent('Bravo')
|
||||||
|
expect(lis[0]).toHaveTextContent('9')
|
||||||
|
expect(lis[1]).toHaveTextContent('Alpha')
|
||||||
|
expect(lis[1]).toHaveAttribute('data-rank', '2')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { LeaderboardRow } from '@/types'
|
||||||
|
|
||||||
|
export interface LeaderboardProps {
|
||||||
|
rows: LeaderboardRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranked teams by average judge total. Pure presentational. */
|
||||||
|
export function Leaderboard({ rows }: LeaderboardProps) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No scores yet.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ol className="space-y-1" data-testid="leaderboard">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<li
|
||||||
|
key={r.teamId}
|
||||||
|
data-rank={i + 1}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground w-5 shrink-0">{String(i + 1).padStart(2, '0')}</span>
|
||||||
|
<span className="text-sm font-medium truncate">{r.teamName || r.teamId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs tabular-nums shrink-0">
|
||||||
|
<span className="font-bold">{r.avgTotal}</span>
|
||||||
|
<span className="text-muted-foreground"> · {r.scoreCount}×</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { ScoreForm } from './ScoreForm'
|
||||||
|
|
||||||
|
describe('ScoreForm', () => {
|
||||||
|
it('sums criteria into a total and submits rubric + notes', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<ScoreForm onSubmit={onSubmit} />)
|
||||||
|
|
||||||
|
const perception = screen.getByLabelText(/perception/i)
|
||||||
|
await user.clear(perception)
|
||||||
|
await user.type(perception, '8')
|
||||||
|
const reasoning = screen.getByLabelText(/reasoning/i)
|
||||||
|
await user.clear(reasoning)
|
||||||
|
await user.type(reasoning, '6')
|
||||||
|
await user.type(screen.getByLabelText(/notes/i), 'solid edge reasoning')
|
||||||
|
|
||||||
|
expect(screen.getByTestId('score-total')).toHaveTextContent('14')
|
||||||
|
await user.click(screen.getByRole('button', { name: /submit score/i }))
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1)
|
||||||
|
const arg = onSubmit.mock.calls[0][0]
|
||||||
|
expect(arg.total).toBe(14)
|
||||||
|
expect(arg.rubric.perception).toBe(8)
|
||||||
|
expect(arg.notes).toBe('solid edge reasoning')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps a criterion to the 0–10 range', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<ScoreForm onSubmit={onSubmit} />)
|
||||||
|
const action = screen.getByLabelText(/action/i)
|
||||||
|
await user.clear(action)
|
||||||
|
await user.type(action, '99')
|
||||||
|
await user.click(screen.getByRole('button', { name: /submit score/i }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].rubric.action).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours the disabled prop', () => {
|
||||||
|
render(<ScoreForm onSubmit={() => {}} disabled />)
|
||||||
|
expect(screen.getByRole('button', { name: /submit score/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
|
const CRITERIA: { key: string; label: string }[] = [
|
||||||
|
{ key: 'perception', label: 'Perception (L1)' },
|
||||||
|
{ key: 'reasoning', label: 'Reasoning (L2)' },
|
||||||
|
{ key: 'action', label: 'Action (L3)' },
|
||||||
|
{ key: 'failure', label: 'Failure modes (L4)' },
|
||||||
|
{ key: 'redesign', label: 'Redesign (L5)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MAX = 10
|
||||||
|
|
||||||
|
export interface ScoreSubmit {
|
||||||
|
rubric: Record<string, number>
|
||||||
|
total: number
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreFormProps {
|
||||||
|
onSubmit: (s: ScoreSubmit) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rubric scoring form — each criterion 0–10, total auto-summed. */
|
||||||
|
export function ScoreForm({ onSubmit, disabled }: ScoreFormProps) {
|
||||||
|
const [rubric, setRubric] = useState<Record<string, number>>(() =>
|
||||||
|
Object.fromEntries(CRITERIA.map((c) => [c.key, 0])),
|
||||||
|
)
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
|
||||||
|
const total = Object.values(rubric).reduce((a, b) => a + b, 0)
|
||||||
|
|
||||||
|
const set = (key: string, raw: string) => {
|
||||||
|
const n = Math.max(0, Math.min(MAX, Number(raw) || 0))
|
||||||
|
setRubric((r) => ({ ...r, [key]: n }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
data-testid="score-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit({ rubric, total, notes })
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{CRITERIA.map((c) => (
|
||||||
|
<div key={c.key} className="flex items-center justify-between gap-3">
|
||||||
|
<label htmlFor={`score-${c.key}`} className="text-sm">{c.label}</label>
|
||||||
|
<Input
|
||||||
|
id={`score-${c.key}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={MAX}
|
||||||
|
value={rubric[c.key]}
|
||||||
|
onChange={(e) => set(c.key, e.target.value)}
|
||||||
|
className="w-20 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="score-notes" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
|
<Textarea id="score-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-mono text-sm">
|
||||||
|
Total <span className="font-bold tabular-nums" data-testid="score-total">{total}</span> / {CRITERIA.length * MAX}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={disabled}>Submit score</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { SubmissionList } from './SubmissionList'
|
||||||
|
import type { SubmissionSummary } from '@/types'
|
||||||
|
|
||||||
|
const items: SubmissionSummary[] = [
|
||||||
|
{ teamId: 'a', teamName: 'Alpha', submittedAt: 'x', scored: false },
|
||||||
|
{ teamId: 'b', teamName: 'Bravo', submittedAt: 'y', scored: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('SubmissionList', () => {
|
||||||
|
it('shows an empty state with no items', () => {
|
||||||
|
render(<SubmissionList items={[]} onSelect={() => {}} />)
|
||||||
|
expect(screen.getByText(/no submissions/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders rows, marks scored, and fires onSelect', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
render(<SubmissionList items={items} selectedId="a" onSelect={onSelect} />)
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/scored/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { current: true })).toHaveTextContent('Alpha')
|
||||||
|
await user.click(screen.getByText('Bravo'))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('b')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { SubmissionSummary } from '@/types'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface SubmissionListProps {
|
||||||
|
items: SubmissionSummary[]
|
||||||
|
selectedId?: string
|
||||||
|
onSelect: (teamId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Judge review queue. Pure presentational. */
|
||||||
|
export function SubmissionList({ items, selectedId, onSelect }: SubmissionListProps) {
|
||||||
|
if (!items.length) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No submissions yet.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-1" data-testid="submission-list">
|
||||||
|
{items.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.teamId}
|
||||||
|
type="button"
|
||||||
|
data-team={s.teamId}
|
||||||
|
aria-current={s.teamId === selectedId}
|
||||||
|
onClick={() => onSelect(s.teamId)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left rounded-md border px-3 py-2 transition',
|
||||||
|
s.teamId === selectedId ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{s.teamName || s.teamId}</span>
|
||||||
|
{s.scored && <Badge className="font-mono text-[8px] uppercase tracking-wider">scored</Badge>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import type { SubmissionDTO } from '@/types'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getSubmissions: vi.fn(),
|
||||||
|
getSubmission: vi.fn(),
|
||||||
|
getLeaderboard: vi.fn(),
|
||||||
|
postScore: vi.fn(),
|
||||||
|
openCollective: vi.fn(() => () => {}),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/api', () => api)
|
||||||
|
|
||||||
|
import { Judge } from './Judge'
|
||||||
|
|
||||||
|
const dto: SubmissionDTO = {
|
||||||
|
teamId: 'a',
|
||||||
|
teamName: 'Alpha',
|
||||||
|
code: 'KIT-01-AAA',
|
||||||
|
add: { L1: { goal: 'g' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' },
|
||||||
|
submittedAt: 'x',
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJudge() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Judge />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Judge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
sessionStorage.setItem('apess_judge_code', 'judge-code')
|
||||||
|
sessionStorage.setItem('apess_judge_code_name', 'Dr. D')
|
||||||
|
api.getSubmissions.mockResolvedValue([{ teamId: 'a', teamName: 'Alpha', submittedAt: 'x', scored: false }])
|
||||||
|
api.getLeaderboard.mockResolvedValue([{ teamId: 'a', teamName: 'Alpha', avgTotal: 0, scoreCount: 0 }])
|
||||||
|
api.getSubmission.mockResolvedValue(dto)
|
||||||
|
api.postScore.mockResolvedValue({ id: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists the submission queue and leaderboard on load', async () => {
|
||||||
|
renderJudge()
|
||||||
|
await waitFor(() => expect(screen.getByTestId('submission-list')).toHaveTextContent('Alpha'))
|
||||||
|
expect(screen.getByTestId('leaderboard')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads a submission for review when selected', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderJudge()
|
||||||
|
await waitFor(() => expect(screen.getByTestId('submission-list')).toBeInTheDocument())
|
||||||
|
await user.click(within(screen.getByTestId('submission-list')).getByText('Alpha'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('add-review')).toHaveTextContent('KIT-01-AAA'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts a score and refreshes', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderJudge()
|
||||||
|
await waitFor(() => expect(screen.getByTestId('submission-list')).toBeInTheDocument())
|
||||||
|
await user.click(within(screen.getByTestId('submission-list')).getByText('Alpha'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('score-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const perception = screen.getByLabelText(/perception/i)
|
||||||
|
await user.clear(perception)
|
||||||
|
await user.type(perception, '7')
|
||||||
|
await user.click(screen.getByRole('button', { name: /submit score/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.postScore).toHaveBeenCalled())
|
||||||
|
const arg = api.postScore.mock.calls[0]
|
||||||
|
expect(arg[0]).toBe('judge-code')
|
||||||
|
expect(arg[1]).toMatchObject({ teamId: 'a', judge: 'Dr. D', total: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gates behind name + code when none stored', () => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
renderJudge()
|
||||||
|
expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { AccessGate } from '@/components/AccessGate'
|
||||||
|
import { SubmissionList } from '@/components/SubmissionList'
|
||||||
|
import { AddReview } from '@/components/AddReview'
|
||||||
|
import { ScoreForm, type ScoreSubmit } from '@/components/ScoreForm'
|
||||||
|
import { Leaderboard } from '@/components/Leaderboard'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
getSubmissions,
|
||||||
|
getSubmission,
|
||||||
|
getLeaderboard,
|
||||||
|
postScore,
|
||||||
|
openCollective,
|
||||||
|
} from '@/lib/api'
|
||||||
|
import type { SubmissionSummary, SubmissionDTO, LeaderboardRow } from '@/types'
|
||||||
|
|
||||||
|
function JudgeDesk({ code, name }: { code: string; name: string }) {
|
||||||
|
const [queue, setQueue] = useState<SubmissionSummary[]>([])
|
||||||
|
const [board, setBoard] = useState<LeaderboardRow[]>([])
|
||||||
|
const [selected, setSelected] = useState<SubmissionDTO | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const [subs, lb] = await Promise.all([getSubmissions(code), getLeaderboard(code)]).catch(() => [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
])
|
||||||
|
if (subs) setQueue(subs)
|
||||||
|
if (lb) setBoard(lb)
|
||||||
|
}, [code])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// initial + live refresh both go through the async refresh (no sync setState)
|
||||||
|
void Promise.resolve().then(refresh)
|
||||||
|
const close = openCollective(code, () => void refresh())
|
||||||
|
return () => close()
|
||||||
|
}, [code, refresh])
|
||||||
|
|
||||||
|
const onSelect = async (teamId: string) => {
|
||||||
|
try {
|
||||||
|
setSelected(await getSubmission(teamId, code))
|
||||||
|
} catch {
|
||||||
|
setSelected(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScore = async (s: ScoreSubmit) => {
|
||||||
|
if (!selected) return
|
||||||
|
try {
|
||||||
|
await postScore(code, { teamId: selected.teamId, judge: name, rubric: s.rubric, total: s.total, notes: s.notes })
|
||||||
|
} catch {
|
||||||
|
/* best-effort; refresh reflects server truth */
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background">
|
||||||
|
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
|
||||||
|
<div className="font-mono text-xs tracking-widest uppercase">
|
||||||
|
APESS <span className="text-primary font-bold">2026</span>
|
||||||
|
<span className="text-muted-foreground"> · Judge</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{name}</span>
|
||||||
|
<Link to="/" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
|
||||||
|
← Landing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="px-8 py-8 max-w-6xl mx-auto grid lg:grid-cols-[260px_1fr_260px] gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Submissions</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SubmissionList items={queue} selectedId={selected?.teamId} onSelect={onSelect} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Review & score</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<AddReview submission={selected} />
|
||||||
|
{selected && <ScoreForm onSubmit={onScore} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Leaderboard</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Leaderboard rows={board} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Judge() {
|
||||||
|
return (
|
||||||
|
<AccessGate codeKey="apess_judge_code" title="Judge access" description="Enter your name and the judge access code." withName>
|
||||||
|
{({ code, name }) => <JudgeDesk code={code} name={name} />}
|
||||||
|
</AccessGate>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user