Files
MOPC-Portal/tests/unit/live-results-tally.test.ts
2026-06-10 18:35:44 +02:00

204 lines
6.7 KiB
TypeScript

/**
* 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<typeof createCaller>
let callerB: ReturnType<typeof createCaller>
let adminCaller: ReturnType<typeof createCaller>
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)
})
})