feat(finale): per-project phase machine, server timers, overtime log, juror notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,61 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma, type PrismaClient, type LiveProgressCursor } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
|
// ─── Grand-finale phase machine helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
type TimingEntry = {
|
||||||
|
projectId: string
|
||||||
|
phase: 'PRESENTING' | 'QA'
|
||||||
|
startedAt: string
|
||||||
|
endedAt: string
|
||||||
|
configuredSeconds: number | null
|
||||||
|
overranSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When leaving a timed phase (PRESENTING/QA), compute the actual elapsed time
|
||||||
|
* (pause-adjusted) and append it to the cursor's timing log. Overtime is
|
||||||
|
* recorded as fact — never as a penalty.
|
||||||
|
*/
|
||||||
|
function closedOutTiming(cursor: LiveProgressCursor, now: Date): Prisma.InputJsonValue | undefined {
|
||||||
|
if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined
|
||||||
|
if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined
|
||||||
|
const end = cursor.phasePausedAt ?? now
|
||||||
|
const elapsedSec = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000)
|
||||||
|
)
|
||||||
|
const entry: TimingEntry = {
|
||||||
|
projectId: cursor.activeProjectId,
|
||||||
|
phase: cursor.projectPhase,
|
||||||
|
startedAt: cursor.phaseStartedAt.toISOString(),
|
||||||
|
endedAt: now.toISOString(),
|
||||||
|
configuredSeconds: cursor.phaseDurationSeconds,
|
||||||
|
overranSeconds:
|
||||||
|
cursor.phaseDurationSeconds == null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
|
||||||
|
}
|
||||||
|
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as unknown as TimingEntry[]) : []
|
||||||
|
return [...log, entry] as unknown as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoundCeremonyConfig(prisma: PrismaClient, roundId: string) {
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||||
|
const cfg = (round.configJson as Record<string, unknown>) ?? {}
|
||||||
|
return {
|
||||||
|
presentationSeconds:
|
||||||
|
typeof cfg.presentationDurationMinutes === 'number'
|
||||||
|
? cfg.presentationDurationMinutes * 60
|
||||||
|
: 300,
|
||||||
|
qaSeconds: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
|
||||||
|
projectOrder: (cfg.projectOrder as string[]) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const liveRouter = router({
|
export const liveRouter = router({
|
||||||
/**
|
/**
|
||||||
* Start a live presentation session for a stage
|
* Start a live presentation session for a stage
|
||||||
@@ -344,6 +396,318 @@ export const liveRouter = router({
|
|||||||
return updated
|
return updated
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale phase machine (ON_DECK → PRESENTING → QA → SCORING)
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a project on every screen as "Up next" — the grace period before the
|
||||||
|
* presentation actually starts. Clears any timer and override slide.
|
||||||
|
*/
|
||||||
|
sendToScreens: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), projectId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
const { projectOrder } = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
|
||||||
|
const index = projectOrder.indexOf(input.projectId)
|
||||||
|
if (index === -1) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not in the session order' })
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
activeProjectId: input.projectId,
|
||||||
|
activeOrderIndex: index,
|
||||||
|
projectPhase: 'ON_DECK',
|
||||||
|
phaseStartedAt: null,
|
||||||
|
phaseDurationSeconds: null,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
overrideSlide: null,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_SEND_TO_SCREENS',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { projectId: input.projectId, orderIndex: index },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the presentation timer for the on-deck project.
|
||||||
|
*/
|
||||||
|
startPresentation: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
durationSeconds: z.number().int().min(10).max(7200).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.activeProjectId) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
|
||||||
|
}
|
||||||
|
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
projectPhase: 'PRESENTING',
|
||||||
|
phaseStartedAt: now,
|
||||||
|
phaseDurationSeconds: input.durationSeconds ?? cfg.presentationSeconds,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_STARTED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: 'PRESENTING', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close out the presentation (logging overtime) and start the Q&A timer.
|
||||||
|
*/
|
||||||
|
startQA: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
durationSeconds: z.number().int().min(10).max(7200).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.activeProjectId) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
|
||||||
|
}
|
||||||
|
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
projectPhase: 'QA',
|
||||||
|
phaseStartedAt: now,
|
||||||
|
phaseDurationSeconds: input.durationSeconds ?? cfg.qaSeconds,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_STARTED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: 'QA', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close out Q&A (logging overtime) and open jury scoring (no timer).
|
||||||
|
*/
|
||||||
|
openScoring: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
projectPhase: 'SCORING',
|
||||||
|
phaseStartedAt: null,
|
||||||
|
phaseDurationSeconds: null,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_STARTED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: 'SCORING', projectId: cursor.activeProjectId },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the running phase timer (e.g. technical difficulties).
|
||||||
|
*/
|
||||||
|
pausePhase: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.phaseStartedAt || cursor.phasePausedAt) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: cursor.phasePausedAt ? 'Timer is already paused' : 'No timer is running',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: { phasePausedAt: new Date() },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_PAUSED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume the paused phase timer, folding paused time into the accumulator.
|
||||||
|
*/
|
||||||
|
resumePhase: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.phasePausedAt) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Timer is not paused' })
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
phasePausedAccumMs:
|
||||||
|
cursor.phasePausedAccumMs + (now.getTime() - cursor.phasePausedAt.getTime()),
|
||||||
|
phasePausedAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_RESUMED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a static slide on the big screen (or clear it).
|
||||||
|
*/
|
||||||
|
setOverrideSlide: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
slide: z.enum(['welcome', 'break', 'deliberation', 'thanks']).nullable(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: { overrideSlide: input.slide },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_OVERRIDE_SLIDE',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { slide: input.slide },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted per-juror per-project ceremony notes (autosaved by the UI;
|
||||||
|
* resurfaced during deliberation).
|
||||||
|
*/
|
||||||
|
saveNote: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
content: z.string().max(20_000),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.liveNote.upsert({
|
||||||
|
where: {
|
||||||
|
roundId_projectId_userId: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
content: input.content,
|
||||||
|
},
|
||||||
|
update: { content: input.content },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
getMyNotes: protectedProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.liveNote.findMany({
|
||||||
|
where: { roundId: input.roundId, userId: ctx.user.id },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current cursor state (for all users, including audience)
|
* Get current cursor state (for all users, including audience)
|
||||||
*/
|
*/
|
||||||
@@ -376,10 +740,22 @@ export const liveRouter = router({
|
|||||||
teamName: true,
|
teamName: true,
|
||||||
description: true,
|
description: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
|
competitionCategory: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full run order with categories (for the admin run-order list and
|
||||||
|
// category grouping on every surface)
|
||||||
|
const orderProjects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectOrder } },
|
||||||
|
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||||
|
})
|
||||||
|
const orderById = new Map(orderProjects.map((p) => [p.id, p]))
|
||||||
|
const orderedProjects = projectOrder
|
||||||
|
.map((id) => orderById.get(id))
|
||||||
|
.filter((p): p is NonNullable<typeof p> => !!p)
|
||||||
|
|
||||||
// Get open cohorts for this round (if any)
|
// Get open cohorts for this round (if any)
|
||||||
const openCohorts = await ctx.prisma.cohort.findMany({
|
const openCohorts = await ctx.prisma.cohort.findMany({
|
||||||
where: { roundId: input.roundId, isOpen: true },
|
where: { roundId: input.roundId, isOpen: true },
|
||||||
@@ -395,6 +771,7 @@ export const liveRouter = router({
|
|||||||
...cursor,
|
...cursor,
|
||||||
activeProject,
|
activeProject,
|
||||||
projectOrder,
|
projectOrder,
|
||||||
|
orderedProjects,
|
||||||
totalProjects: projectOrder.length,
|
totalProjects: projectOrder.length,
|
||||||
openCohorts,
|
openCohorts,
|
||||||
}
|
}
|
||||||
|
|||||||
167
tests/unit/live-phase.test.ts
Normal file
167
tests/unit/live-phase.test.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* 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('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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user