/** * 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 let jurorCaller: ReturnType 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 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 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).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 caller’s notes', async () => { const adminNotes = await adminCaller.getMyNotes({ roundId: round.id }) expect(adminNotes).toHaveLength(0) }) })