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