From f7fdfdec9b59efb3d96dd8c7a6e2ec5d089c2c90 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 18:35:44 +0200 Subject: [PATCH] =?UTF-8?q?test(finale):=20tally=20audit=20=E2=80=94=20wei?= =?UTF-8?q?ghted=20criteria=20math,=20ordering,=20ties,=20favorites=20sepa?= =?UTF-8?q?ration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/live-results-tally.test.ts | 203 ++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/unit/live-results-tally.test.ts diff --git a/tests/unit/live-results-tally.test.ts b/tests/unit/live-results-tally.test.ts new file mode 100644 index 0000000..5e9e42b --- /dev/null +++ b/tests/unit/live-results-tally.test.ts @@ -0,0 +1,203 @@ +/** + * 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) + }) +})