2026-06-10 18:07:02 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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()
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-10 18:15:45 +02:00
|
|
|
|
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')
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-10 18:07:02 +02:00
|
|
|
|
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 caller’s notes', async () => {
|
|
|
|
|
|
const adminNotes = await adminCaller.getMyNotes({ roundId: round.id })
|
|
|
|
|
|
expect(adminNotes).toHaveLength(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|