Files
esc/packages/server/tests/game-manager.test.ts
2026-03-12 19:50:07 +01:00

322 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest"
import { GameManager } from "../src/games/game-manager"
describe("GameManager", () => {
let gm: GameManager
beforeEach(() => {
gm = new GameManager()
})
describe("lineup", () => {
it("returns the ESC 2025 lineup", () => {
const lineup = gm.getLineup()
expect(lineup.year).toBe(2025)
expect(lineup.entries.length).toBeGreaterThan(20)
expect(lineup.entries[0]).toHaveProperty("country")
expect(lineup.entries[0]).toHaveProperty("artist")
expect(lineup.entries[0]).toHaveProperty("song")
expect(lineup.entries[0]?.country).toHaveProperty("flag")
})
it("validates country codes", () => {
expect(gm.isValidCountry("DE")).toBe(true)
expect(gm.isValidCountry("XX")).toBe(false)
})
})
describe("predictions", () => {
it("accepts a valid prediction", () => {
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
expect(result).toEqual({ success: true })
expect(gm.getPrediction("p1")).toEqual({
playerId: "p1",
first: "SE",
second: "DE",
third: "IT",
last: "GB",
})
})
it("rejects prediction with invalid country", () => {
const result = gm.submitPrediction("p1", "XX", "DE", "IT", "GB")
expect(result).toEqual({ error: "Invalid country: XX" })
})
it("rejects duplicate picks", () => {
const result = gm.submitPrediction("p1", "SE", "SE", "IT", "GB")
expect(result).toEqual({ error: "All 4 picks must be different countries" })
})
it("rejects last same as first", () => {
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "SE")
expect(result).toEqual({ error: "All 4 picks must be different countries" })
})
it("allows overwriting a prediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
gm.submitPrediction("p1", "NO", "DE", "IT", "GB")
expect(gm.getPrediction("p1")?.first).toBe("NO")
})
it("rejects prediction when locked", () => {
gm.lockPredictions()
const result = gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
expect(result).toEqual({ error: "Predictions are locked" })
})
it("tracks prediction submission status", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
expect(gm.hasPrediction("p1")).toBe(true)
expect(gm.hasPrediction("p2")).toBe(false)
})
})
describe("bingo", () => {
it("generates a bingo card with 16 unique squares", () => {
gm.generateBingoCards(["p1", "p2"])
const card = gm.getBingoCard("p1")
expect(card).not.toBeNull()
expect(card!.squares).toHaveLength(16)
expect(card!.hasBingo).toBe(false)
const ids = card!.squares.map((s) => s.tropeId)
expect(new Set(ids).size).toBe(16)
})
it("generates different cards for different players", () => {
gm.generateBingoCards(["p1", "p2"])
const card1 = gm.getBingoCard("p1")!
const card2 = gm.getBingoCard("p2")!
const ids1 = card1.squares.map((s) => s.tropeId).sort()
const ids2 = card2.squares.map((s) => s.tropeId).sort()
expect(ids1).not.toEqual(ids2)
})
it("taps a bingo square", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
const result = gm.tapBingoSquare("p1", tropeId)
expect(result).toMatchObject({ success: true, hasBingo: false })
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(true)
})
it("rejects tap on unknown trope", () => {
gm.generateBingoCards(["p1"])
const result = gm.tapBingoSquare("p1", "nonexistent")
expect(result).toEqual({ error: "Trope not on your card" })
})
it("rejects tap when no card exists", () => {
const result = gm.tapBingoSquare("p1", "key-change")
expect(result).toEqual({ error: "No bingo card found" })
})
it("allows untapping a square", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
const tropeId = card.squares[0]!.tropeId
gm.tapBingoSquare("p1", tropeId)
gm.tapBingoSquare("p1", tropeId)
expect(gm.getBingoCard("p1")!.squares[0]!.tapped).toBe(false)
})
it("detects bingo on a completed row", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (let i = 0; i < 4; i++) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
})
it("detects bingo on a completed column", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (const i of [0, 4, 8, 12]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("detects bingo on a diagonal", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
let result: any
for (const i of [0, 5, 10, 15]) {
result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(result).toMatchObject({ success: true, hasBingo: true })
})
it("revokes bingo when a completing square is untapped", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.getBingoCard("p1")!.hasBingo).toBe(true)
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
expect(gm.getBingoCard("p1")!.hasBingo).toBe(false)
})
it("does not duplicate bingo announcements on re-bingo", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(true)
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // untap
gm.tapBingoSquare("p1", card.squares[0]!.tropeId) // re-tap → re-bingo
expect(gm.addBingoAnnouncement("p1", "Player 1")).toBe(false)
expect(gm.getBingoAnnouncements()).toHaveLength(1)
})
it("computes bingo score", () => {
gm.generateBingoCards(["p1"])
const card = gm.getBingoCard("p1")!
for (let i = 0; i < 4; i++) {
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
}
gm.tapBingoSquare("p1", card.squares[4]!.tropeId)
// 5 tapped * 2 + 10 bingo bonus = 20
expect(gm.getBingoScore("p1")).toBe(20)
})
})
describe("getGameStateForPlayer", () => {
it("includes only the requesting player's prediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
gm.submitPrediction("p2", "NO", "DE", "IT", "GB")
const state = gm.getGameStateForPlayer("p1", ["p1", "p2"])
expect(state.myPrediction?.first).toBe("SE")
})
it("includes predictionSubmitted for all players", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
const state = gm.getGameStateForPlayer("p1", ["p1", "p2"])
expect(state.predictionSubmitted).toEqual({ p1: true, p2: false })
})
})
describe("jury voting", () => {
it("opens a jury round", () => {
const result = gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(result).toEqual({ success: true })
const round = gm.getCurrentJuryRound()
expect(round).not.toBeNull()
expect(round!.countryCode).toBe("SE")
expect(round!.status).toBe("open")
})
it("rejects opening when a round is already open", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.openJuryRound("DE", "Germany", "🇩🇪")
expect(result).toEqual({ error: "A jury round is already open" })
})
it("accepts a valid jury vote", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ success: true })
})
it("rejects jury vote when no round is open", () => {
const result = gm.submitJuryVote("p1", 8)
expect(result).toEqual({ error: "No jury round is open" })
})
it("rejects jury vote outside 1-12 range", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.submitJuryVote("p1", 0)
expect(result).toEqual({ error: "Rating must be between 1 and 12" })
})
it("allows overwriting a jury vote in the same round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p1", 10)
const result = gm.closeJuryRound()
expect("averageRating" in result && result.averageRating).toBe(10)
})
it("closes a jury round and computes average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 8)
gm.submitJuryVote("p2", 10)
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 9,
totalVotes: 2,
})
})
it("rejects close when no round is open", () => {
const result = gm.closeJuryRound()
expect(result).toEqual({ error: "No jury round is open" })
})
it("handles closing a round with zero votes", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
const result = gm.closeJuryRound()
expect(result).toMatchObject({
countryCode: "SE",
averageRating: 0,
totalVotes: 0,
})
})
it("accumulates results across rounds", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10)
gm.closeJuryRound()
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 6)
gm.closeJuryRound()
expect(gm.getJuryResults()).toHaveLength(2)
})
it("computes jury scores based on closeness to average", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
gm.submitJuryVote("p1", 10) // avg will be 10, diff=0, score=5
gm.submitJuryVote("p2", 10) // diff=0, score=5
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(5)
expect(gm.getJuryScore("p2")).toBe(5)
gm.openJuryRound("DE", "Germany", "🇩🇪")
gm.submitJuryVote("p1", 4) // avg=(4+10)/2=7, diff=3, score=2
gm.submitJuryVote("p2", 10) // diff=3, score=2
gm.closeJuryRound()
expect(gm.getJuryScore("p1")).toBe(7) // 5+2
expect(gm.getJuryScore("p2")).toBe(7) // 5+2
})
it("returns the player's current vote for a round", () => {
gm.openJuryRound("SE", "Sweden", "🇸🇪")
expect(gm.getPlayerJuryVote("p1")).toBeNull()
gm.submitJuryVote("p1", 7)
expect(gm.getPlayerJuryVote("p1")).toBe(7)
})
})
describe("getGameStateForDisplay", () => {
it("returns null myPrediction", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
const state = gm.getGameStateForDisplay(["p1"])
expect(state.myPrediction).toBeNull()
})
it("includes predictionSubmitted", () => {
gm.submitPrediction("p1", "SE", "DE", "IT", "GB")
const state = gm.getGameStateForDisplay(["p1", "p2"])
expect(state.predictionSubmitted).toEqual({ p1: true, p2: false })
})
})
})