/** * Tally audit — these numbers go on a projector. Pins: * - criteria-mode weighted score computation on vote submit (hand-computed) * - getResults jury aggregation + ordering * - tie detection * - audience favorite tallies stay separate from jury scores (separate awards) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestCompetition, createTestRound, createTestProject, cleanupTestData, uid, } from '../helpers' import { liveVotingRouter } from '@/server/routers/live-voting' let program: any let round: any let session: any let p1: any let p2: any let jurorA: any let jurorB: any let admin: any let callerA: ReturnType let callerB: ReturnType let adminCaller: ReturnType const CRITERIA = [ { id: 'impact', label: 'Impact', scale: 10, weight: 0.6 }, { id: 'feasibility', label: 'Feasibility', scale: 5, weight: 0.4 }, ] beforeAll(async () => { program = await createTestProgram() const competition = await createTestCompetition(program.id) const juryGroup = await prisma.juryGroup.create({ data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') }, }) round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }) await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: juryGroup.id } }) p1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) p2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) await prisma.round.update({ where: { id: round.id }, data: { configJson: { projectOrder: [p1.id, p2.id], presentationDurationMinutes: 5, }, }, }) jurorA = await createTestUser('JURY_MEMBER') jurorB = await createTestUser('JURY_MEMBER') admin = await createTestUser('SUPER_ADMIN') for (const j of [jurorA, jurorB]) { await prisma.juryGroupMember.create({ data: { juryGroupId: juryGroup.id, userId: j.id, role: 'MEMBER' }, }) } session = await prisma.liveVotingSession.create({ data: { roundId: round.id, status: 'IN_PROGRESS', currentProjectId: p1.id, votingMode: 'criteria', criteriaJson: CRITERIA, }, }) callerA = createCaller(liveVotingRouter, jurorA) callerB = createCaller(liveVotingRouter, jurorB) adminCaller = createCaller(liveVotingRouter, admin) }) afterAll(async () => { await cleanupTestData(program.id, [jurorA.id, jurorB.id, admin.id]) }) describe('criteria-mode weighted score', () => { it('computes the documented weighted normalization on submit', async () => { // impact 8/10 → 8.0 normalized ·0.6 = 4.8 ; feasibility 4/5 → 8.0 ·0.4 = 3.2 // weighted sum = 8.0 → stored score 8 const vote = await callerA.vote({ sessionId: session.id, projectId: p1.id, score: 1, // ignored in criteria mode — recomputed server-side criterionScores: { impact: 8, feasibility: 4 }, }) expect(vote.score).toBe(8) }) it('rejects an out-of-scale criterion score', async () => { await expect( callerA.vote({ sessionId: session.id, projectId: p1.id, score: 1, criterionScores: { impact: 11, feasibility: 4 }, }) ).rejects.toThrow(/between 1 and/) }) it('rejects a missing criterion', async () => { await expect( callerA.vote({ sessionId: session.id, projectId: p1.id, score: 1, criterionScores: { impact: 8 }, }) ).rejects.toThrow(/Missing score/) }) }) describe('getResults aggregation', () => { it('averages jury votes per project and orders by weighted total', async () => { // jurorB scores p1: impact 4/10 → 2.4, feasibility 2/5 → 1.6 → 4 await callerB.vote({ sessionId: session.id, projectId: p1.id, score: 1, criterionScores: { impact: 4, feasibility: 2 }, }) // both jurors score p2 (revision path: p2 is in the order) // jurorA: impact 10/10 → 6.0, feasibility 5/5 → 4.0 → 10 await callerA.vote({ sessionId: session.id, projectId: p2.id, score: 1, criterionScores: { impact: 10, feasibility: 5 }, }) // jurorB: impact 6/10 → 3.6, feasibility 3/5 → 2.4 → 6 await callerB.vote({ sessionId: session.id, projectId: p2.id, score: 1, criterionScores: { impact: 6, feasibility: 3 }, }) const res = await adminCaller.getResults({ sessionId: session.id }) // p1: (8+4)/2 = 6 ; p2: (10+6)/2 = 8 → p2 first expect(res.results[0].project?.id).toBe(p2.id) expect(res.results[0].juryAverage).toBe(8) expect(res.results[0].juryVoteCount).toBe(2) expect(res.results[1].project?.id).toBe(p1.id) expect(res.results[1].juryAverage).toBe(6) // audience weight defaults to 0 → weighted total equals jury average expect(res.results[0].weightedTotal).toBe(8) expect(res.weights.audience).toBe(0) }) it('detects ties', async () => { // pull p1 up to match p2's average: change jurorB's p1 vote to 10 → (8+10)/2 = 9? no. // make both averages equal: set jurorA p1 → 10 (impact 10, feas 5) → p1 avg (10+4)/2 = 7 // and jurorA p2 → 8 (impact 8, feas 4) → p2 avg (8+6)/2 = 7 await callerA.vote({ sessionId: session.id, projectId: p1.id, score: 1, criterionScores: { impact: 10, feasibility: 5 }, }) await callerA.vote({ sessionId: session.id, projectId: p2.id, score: 1, criterionScores: { impact: 8, feasibility: 4 }, }) const res = await adminCaller.getResults({ sessionId: session.id }) expect(res.results[0].weightedTotal).toBe(res.results[1].weightedTotal) expect(res.ties.length).toBeGreaterThan(0) }) }) describe('audience favorites stay separate', () => { it('favorite tallies do not touch jury weighted totals', async () => { await prisma.liveVotingSession.update({ where: { id: session.id }, data: { allowAudienceVotes: true }, }) await adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'CATEGORY:STARTUP', durationMinutes: 5, }) const reg = await adminCaller.registerAudienceVoter({ sessionId: session.id }) await adminCaller.castFavoriteVote({ sessionId: session.id, token: reg.token, projectId: p1.id, }) const tallies = await adminCaller.getFavoriteTallies({ sessionId: session.id }) expect(tallies.windows[0].totalVotes).toBe(1) const res = await adminCaller.getResults({ sessionId: session.id }) // favorite votes are NOT LiveVotes — jury aggregation unchanged expect(res.results.every((r: any) => r.audienceVoteCount === 0)).toBe(true) }) })