test(finale): tally audit — weighted criteria math, ordering, ties, favorites separation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
203
tests/unit/live-results-tally.test.ts
Normal file
203
tests/unit/live-results-tally.test.ts
Normal file
@@ -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<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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user