Files
MOPC-Portal/tests/unit/live-phase.test.ts
Matt c9dc1bfabd feat(finale): deliberation jury identity resolution, rankable projects, score-revision path, session sync
- submitVote resolves the caller's JuryGroupMember participant row server-side
  (was comparing JuryGroupMember id to User id — every juror got FORBIDDEN)
- getSessionWithVotes now includes category projects so the ranking form has
  data before finalize
- liveVoting.vote accepts any finale-ordered project (revision during
  deliberation); timed window still applies to the live project
- live.sendToScreens keeps LiveVotingSession.currentProjectId/status in sync

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

182 lines
7.2 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')
})
})
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)
})
})