322 lines
10 KiB
TypeScript
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 })
|
|
})
|
|
})
|
|
})
|