Files
MOPC-Portal/tests/unit/live-phase.test.ts
Matt 2945a92193
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
feat(finale): per-project presentation/Q&A durations in m:ss + config-save merge fix
- setProjectTiming stores per-project overrides in round config; phase starts
  resolve: explicit input > project override > round default
- Run Order rows get m:ss inputs per project; PhaseControls one-off overrides
  now also m:ss (shared parseClock: '7:30', '12:05', plain '7')
- CRITICAL: round.update now MERGES validated form config over the existing
  configJson — saving the Config tab was wiping projectOrder (would have
  destroyed a running ceremony) and the finals-docs upload toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:14:49 +02:00

247 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Grand-finale per-project phase machine on LiveProgressCursor:
* ON_DECK → PRESENTING → QA → SCORING, with server-stamped timers,
* pause/resume accumulator math, an overtime timing log, big-screen
* override slides, and persisted juror notes.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
} from '../helpers'
import { liveRouter } from '@/server/routers/live'
let program: any
let round: any
let p1: any
let p2: any
let admin: any
let juror: any
let adminCaller: ReturnType<typeof createCaller>
let jurorCaller: ReturnType<typeof createCaller>
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
status: 'ROUND_ACTIVE',
configJson: { presentationDurationMinutes: 2, qaDurationMinutes: 1 },
})
p1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
p2 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
admin = await createTestUser('SUPER_ADMIN')
juror = await createTestUser('JURY_MEMBER')
adminCaller = createCaller(liveRouter, admin)
jurorCaller = createCaller(liveRouter, juror)
await adminCaller.start({ roundId: round.id, projectOrder: [p1.id, p2.id] })
})
afterAll(async () => {
await cleanupTestData(program.id, [admin.id, juror.id])
})
describe('phase transitions', () => {
it('sendToScreens puts a project ON_DECK with no timer', async () => {
const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
expect(cursor.projectPhase).toBe('ON_DECK')
expect(cursor.activeProjectId).toBe(p1.id)
expect(cursor.phaseStartedAt).toBeNull()
expect(cursor.phaseDurationSeconds).toBeNull()
expect(cursor.overrideSlide).toBeNull()
})
it('rejects sendToScreens for a project outside the order', async () => {
await expect(
adminCaller.sendToScreens({ roundId: round.id, projectId: 'nope' })
).rejects.toThrow()
})
it('startPresentation stamps the timer with config default duration', async () => {
const cursor = await adminCaller.startPresentation({ roundId: round.id })
expect(cursor.projectPhase).toBe('PRESENTING')
expect(cursor.phaseStartedAt).not.toBeNull()
expect(cursor.phaseDurationSeconds).toBe(120) // presentationDurationMinutes: 2
expect(cursor.phasePausedAccumMs).toBe(0)
})
it('pause/resume folds pause time into the accumulator', async () => {
const paused = await adminCaller.pausePhase({ roundId: round.id })
expect(paused.phasePausedAt).not.toBeNull()
// pausing twice errors
await expect(adminCaller.pausePhase({ roundId: round.id })).rejects.toThrow()
// backdate the pause so the accumulator visibly grows
await prisma.liveProgressCursor.update({
where: { roundId: round.id },
data: { phasePausedAt: new Date(Date.now() - 5_000) },
})
const resumed = await adminCaller.resumePhase({ roundId: round.id })
expect(resumed.phasePausedAt).toBeNull()
expect(resumed.phasePausedAccumMs).toBeGreaterThanOrEqual(5_000)
// resuming while not paused errors
await expect(adminCaller.resumePhase({ roundId: round.id })).rejects.toThrow()
})
it('startQA logs the presentation with overtime and starts the QA timer', async () => {
// Backdate the presentation start so it overran its 120s budget
await prisma.liveProgressCursor.update({
where: { roundId: round.id },
data: { phaseStartedAt: new Date(Date.now() - 200_000), phasePausedAccumMs: 0 },
})
const cursor = await adminCaller.startQA({ roundId: round.id, durationSeconds: 30 })
expect(cursor.projectPhase).toBe('QA')
expect(cursor.phaseDurationSeconds).toBe(30)
const log = cursor.timingLogJson as Array<any>
expect(log).toHaveLength(1)
expect(log[0].projectId).toBe(p1.id)
expect(log[0].phase).toBe('PRESENTING')
expect(log[0].configuredSeconds).toBe(120)
expect(log[0].overranSeconds).toBeGreaterThanOrEqual(79) // ~200s elapsed vs 120s budget
})
it('openScoring logs the QA phase and clears the timer', async () => {
const cursor = await adminCaller.openScoring({ roundId: round.id })
expect(cursor.projectPhase).toBe('SCORING')
expect(cursor.phaseStartedAt).toBeNull()
const log = cursor.timingLogJson as Array<any>
expect(log).toHaveLength(2)
expect(log[1].phase).toBe('QA')
expect(log[1].configuredSeconds).toBe(30)
})
it('sending the next project keeps the timing log', async () => {
const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p2.id })
expect(cursor.activeProjectId).toBe(p2.id)
expect(cursor.projectPhase).toBe('ON_DECK')
expect((cursor.timingLogJson as Array<any>).length).toBe(2)
})
it('setOverrideSlide sets and clears the big-screen override', async () => {
const set = await adminCaller.setOverrideSlide({ roundId: round.id, slide: 'break' })
expect(set.overrideSlide).toBe('break')
const cleared = await adminCaller.setOverrideSlide({ roundId: round.id, slide: null })
expect(cleared.overrideSlide).toBeNull()
})
it('getCursor exposes phase fields and ordered projects with categories', async () => {
const cursor = await jurorCaller.getCursor({ roundId: round.id })
expect(cursor?.projectPhase).toBe('ON_DECK')
expect(cursor?.orderedProjects?.map((p: any) => p.id)).toEqual([p1.id, p2.id])
expect(cursor?.orderedProjects?.[0]?.competitionCategory).toBe('STARTUP')
expect(cursor?.activeProject?.competitionCategory).toBe('BUSINESS_CONCEPT')
})
it('phase mutations are admin-only', async () => {
await expect(
jurorCaller.startPresentation({ roundId: round.id })
).rejects.toThrow()
})
})
describe('voting session sync', () => {
it('sendToScreens points the voting session at the project and activates it', async () => {
const session = await prisma.liveVotingSession.create({
data: { roundId: round.id, status: 'NOT_STARTED' },
})
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const updated = await prisma.liveVotingSession.findUniqueOrThrow({
where: { id: session.id },
})
expect(updated.currentProjectId).toBe(p1.id)
expect(updated.status).toBe('IN_PROGRESS')
})
it('startPresentation re-syncs a session that drifted out of band', async () => {
await prisma.liveVotingSession.update({
where: { roundId: round.id },
data: { status: 'NOT_STARTED', currentProjectId: null },
})
await adminCaller.startPresentation({ roundId: round.id, durationSeconds: 60 })
const updated = await prisma.liveVotingSession.findUniqueOrThrow({
where: { roundId: round.id },
})
expect(updated.currentProjectId).toBe(p1.id)
expect(updated.status).toBe('IN_PROGRESS')
})
})
describe('per-project timing overrides', () => {
it('setProjectTiming stores per-project durations in round config', async () => {
await adminCaller.setProjectTiming({
roundId: round.id,
projectId: p1.id,
presentationSeconds: 480,
qaSeconds: 90,
})
const r = await prisma.round.findUniqueOrThrow({ where: { id: round.id } })
const overrides = (r.configJson as any).projectTimingOverrides
expect(overrides[p1.id]).toEqual({ presentationSeconds: 480, qaSeconds: 90 })
})
it('startPresentation/startQA use the project override over the config default', async () => {
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const pres = await adminCaller.startPresentation({ roundId: round.id })
expect(pres.phaseDurationSeconds).toBe(480) // override, not the 120s config default
const qa = await adminCaller.startQA({ roundId: round.id })
expect(qa.phaseDurationSeconds).toBe(90)
})
it('an explicit durationSeconds input still wins over the project override', async () => {
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const pres = await adminCaller.startPresentation({ roundId: round.id, durationSeconds: 33 })
expect(pres.phaseDurationSeconds).toBe(33)
})
it('projects without an override keep the config default', async () => {
await adminCaller.sendToScreens({ roundId: round.id, projectId: p2.id })
const pres = await adminCaller.startPresentation({ roundId: round.id })
expect(pres.phaseDurationSeconds).toBe(120)
})
it('clearing an override falls back to defaults', async () => {
await adminCaller.setProjectTiming({
roundId: round.id,
projectId: p1.id,
presentationSeconds: null,
qaSeconds: null,
})
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const pres = await adminCaller.startPresentation({ roundId: round.id })
expect(pres.phaseDurationSeconds).toBe(120)
})
it('getCursor exposes the overrides for the admin UI', async () => {
await adminCaller.setProjectTiming({ roundId: round.id, projectId: p2.id, qaSeconds: 240 })
const cursor = await adminCaller.getCursor({ roundId: round.id })
expect(cursor?.projectTimingOverrides?.[p2.id]?.qaSeconds).toBe(240)
})
})
describe('juror notes', () => {
it('saveNote upserts one note per (round, project, juror)', async () => {
await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'first draft' })
await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'revised' })
await jurorCaller.saveNote({ roundId: round.id, projectId: p2.id, content: 'other project' })
const notes = await jurorCaller.getMyNotes({ roundId: round.id })
expect(notes).toHaveLength(2)
const n1 = notes.find((n: any) => n.projectId === p1.id)
expect(n1?.content).toBe('revised')
})
it('getMyNotes only returns the callers notes', async () => {
const adminNotes = await adminCaller.getMyNotes({ roundId: round.id })
expect(adminNotes).toHaveLength(0)
})
})