feat: add award winner resolver with tiebreak logic and tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
37
src/server/services/award-winner-resolver.ts
Normal file
37
src/server/services/award-winner-resolver.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the winner of a PICK_WINNER award given all votes and the chair's userId.
|
||||||
|
* Logic: count votes per project. If one project has the most, it wins.
|
||||||
|
* If tied, the chair's pick wins among the tied projects.
|
||||||
|
*/
|
||||||
|
export function resolveAwardWinner(
|
||||||
|
votes: Array<{ userId: string; projectId: string }>,
|
||||||
|
chairUserId: string
|
||||||
|
): string {
|
||||||
|
if (votes.length === 0) {
|
||||||
|
throw new Error('Cannot resolve winner with no votes')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tally votes per project
|
||||||
|
const tally = new Map<string, number>()
|
||||||
|
for (const v of votes) {
|
||||||
|
tally.set(v.projectId, (tally.get(v.projectId) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVotes = Math.max(...tally.values())
|
||||||
|
const topProjects = [...tally.entries()]
|
||||||
|
.filter(([, count]) => count === maxVotes)
|
||||||
|
.map(([pid]) => pid)
|
||||||
|
|
||||||
|
if (topProjects.length === 1) {
|
||||||
|
return topProjects[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tie: chair's pick wins if among tied projects
|
||||||
|
const chairVote = votes.find((v) => v.userId === chairUserId)
|
||||||
|
if (chairVote && topProjects.includes(chairVote.projectId)) {
|
||||||
|
return chairVote.projectId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chair voted for a non-tied project — pick alphabetically for stability
|
||||||
|
return topProjects.sort()[0]
|
||||||
|
}
|
||||||
47
tests/unit/award-master.test.ts
Normal file
47
tests/unit/award-master.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { resolveAwardWinner } from '@/server/services/award-winner-resolver'
|
||||||
|
|
||||||
|
describe('resolveAwardWinner', () => {
|
||||||
|
it('returns the sole voted project when all agree', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'u1', projectId: 'p1' },
|
||||||
|
{ userId: 'u2', projectId: 'p1' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'u1')).toBe('p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the majority winner when no tie', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'u1', projectId: 'p1' },
|
||||||
|
{ userId: 'u2', projectId: 'p2' },
|
||||||
|
{ userId: 'u3', projectId: 'p1' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'u1')).toBe('p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses chair vote as tiebreaker', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'chair', projectId: 'p2' },
|
||||||
|
{ userId: 'u2', projectId: 'p1' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'chair')).toBe('p2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns chair pick when tied and chair voted for one of the tied projects', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'chair', projectId: 'p3' },
|
||||||
|
{ userId: 'u2', projectId: 'p1' },
|
||||||
|
{ userId: 'u3', projectId: 'p2' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'chair')).toBe('p3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns sole vote for solo sponsor', () => {
|
||||||
|
const votes = [{ userId: 'sponsor', projectId: 'p1' }]
|
||||||
|
expect(resolveAwardWinner(votes, 'sponsor')).toBe('p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if no votes', () => {
|
||||||
|
expect(() => resolveAwardWinner([], 'chair')).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user