This commit is contained in:
Dorian
2026-04-11 19:46:37 +01:00
parent 52752a92bf
commit 32e6c19f72
44 changed files with 5511 additions and 39 deletions
+543
View File
@@ -0,0 +1,543 @@
/**
* Tournament engine tests — bracket generation, match scheduling,
* elimination logic, and round progression.
*
* Uses vi.mock to swap the global db/schema/sqlite singleton with an
* in-memory test database so every test gets a clean slate.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createTestDb, insertTestBot } from '../test-helpers/db.js'
import { nanoid } from 'nanoid'
// Swap the db module before tournament code imports it
let testDb: ReturnType<typeof createTestDb>
vi.mock('../db/index.js', () => {
// Lazy — the actual testDb is assigned in beforeEach,
// but the module proxy always dereferences the live binding.
return {
get db() { return testDb.db },
get schema() { return testDb.schema },
get sqlite() { return testDb.sqlite },
}
})
// Import AFTER mock is registered so the module picks up the proxy
import {
createTournament,
joinTournament,
startTournament,
getTournamentBracket,
listTournaments,
getPendingMatches,
linkFightToMatch,
onFightFinished,
} from './tournaments.js'
import { eq, and } from 'drizzle-orm'
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
/** Insert N bots with ascending ELO (1200, 1300, 1400 ...) */
function seedBots(count: number) {
const bots = []
for (let i = 0; i < count; i++) {
const bot = insertTestBot(testDb.db, {
id: `bot-${i}`,
name: `Fighter-${i}`,
eloRating: 1200 + i * 100,
})
bots.push(bot)
}
return bots
}
/** Create a tournament and fill it with bots, returning the tournament id and bot ids */
function createAndFill(size: 8 | 16 | 32, botCount: number) {
const bots = seedBots(botCount)
const tid = createTournament(`Test-${size}`, 'single_elim', size, 0)
for (const bot of bots) {
joinTournament(tid, bot.id)
}
return { tid, bots }
}
/**
* Insert a fight record into the DB so FK constraints are satisfied
* when linking fights to tournament matches.
*/
function insertFight(fightId: string, botAId: string, botBId: string) {
testDb.db.insert(testDb.schema.fights).values({
id: fightId,
botAId,
botBId,
arena: 'test-arena',
status: 'live',
createdAt: new Date().toISOString(),
}).run()
}
/**
* Link a fight to a match with FK-safe fight insertion.
* Creates the fight record, then links it to the match.
*/
function safeLink(matchId: string, fightId: string, botAId: string, botBId: string) {
insertFight(fightId, botAId, botBId)
linkFightToMatch(matchId, fightId)
}
// ---------------------------------------------------------------------------
// setup
// ---------------------------------------------------------------------------
beforeEach(() => {
testDb = createTestDb()
})
// ---------------------------------------------------------------------------
// createTournament
// ---------------------------------------------------------------------------
describe('createTournament', () => {
it('creates tournament with correct defaults', () => {
const id = createTournament('Halvening Cup', 'single_elim', 8, 500)
const all = listTournaments()
expect(all).toHaveLength(1)
const t = all[0]
expect(t.id).toBe(id)
expect(t.name).toBe('Halvening Cup')
expect(t.format).toBe('single_elim')
expect(t.size).toBe(8)
expect(t.entrySats).toBe(500)
expect(t.prizeSats).toBe(4000) // 500 * 8
expect(t.status).toBe('open')
expect(t.currentRound).toBe(0)
})
it('creates free tournament (zero entry fee)', () => {
createTournament('Free Arena', 'single_elim', 16)
const all = listTournaments()
expect(all[0].entrySats).toBe(0)
expect(all[0].prizeSats).toBe(0)
})
it('listTournaments filters by status', () => {
createTournament('Open1', 'single_elim', 8)
createTournament('Open2', 'single_elim', 8)
expect(listTournaments('open')).toHaveLength(2)
expect(listTournaments('active')).toHaveLength(0)
expect(listTournaments('finished')).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// joinTournament
// ---------------------------------------------------------------------------
describe('joinTournament', () => {
it('adds bot entry', () => {
const bots = seedBots(1)
const tid = createTournament('Join Test', 'single_elim', 8)
const entryId = joinTournament(tid, bots[0].id)
expect(entryId).toBeTruthy()
const bracket = getTournamentBracket(tid)!
expect(bracket.entries).toHaveLength(1)
expect(bracket.entries[0].botId).toBe(bots[0].id)
})
it('rejects duplicate entry', () => {
const bots = seedBots(1)
const tid = createTournament('Dup Test', 'single_elim', 8)
joinTournament(tid, bots[0].id)
expect(() => joinTournament(tid, bots[0].id))
.toThrow('Bot already entered in this tournament')
})
it('rejects entry to nonexistent tournament', () => {
const bots = seedBots(1)
expect(() => joinTournament('fake-id', bots[0].id))
.toThrow('Tournament not found')
})
it('rejects entry when tournament is full', () => {
const { tid } = createAndFill(8, 8)
const extra = insertTestBot(testDb.db, { id: 'bot-extra', name: 'ExtraBot' })
expect(() => joinTournament(tid, extra.id))
.toThrow('Tournament is full')
})
it('rejects entry to non-open tournament', () => {
const { tid } = createAndFill(8, 4)
startTournament(tid)
const extra = insertTestBot(testDb.db, { id: 'bot-late', name: 'LateBot' })
expect(() => joinTournament(tid, extra.id))
.toThrow('Tournament is not accepting entries')
})
})
// ---------------------------------------------------------------------------
// startTournament & bracket generation
// ---------------------------------------------------------------------------
describe('startTournament', () => {
it('requires at least 2 entries', () => {
const bots = seedBots(1)
const tid = createTournament('Tiny', 'single_elim', 8)
joinTournament(tid, bots[0].id)
expect(() => startTournament(tid)).toThrow('Need at least 2 entries')
})
it('rejects double-start', () => {
const { tid } = createAndFill(8, 4)
startTournament(tid)
expect(() => startTournament(tid)).toThrow('Tournament already started')
})
it('sets status to active and currentRound >= 1', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
expect(bracket.tournament.status).toBe('active')
expect(bracket.tournament.currentRound).toBeGreaterThanOrEqual(1)
expect(bracket.tournament.startedAt).toBeTruthy()
})
})
describe('bracket generation — 8 bots (full bracket)', () => {
it('generates 4 round-1 matches for 8 bots', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const r1 = bracket.matches.filter(m => m.round === 1)
expect(r1).toHaveLength(4)
})
it('seeds by ELO: highest vs lowest', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const r1 = bracket.matches.filter(m => m.round === 1).sort((a, b) => a.matchIndex - b.matchIndex)
// Seed 1 (highest ELO = bot-7) vs Seed 8 (lowest = bot-0) in match 0
expect(r1[0].botAId).toBe('bot-7')
expect(r1[0].botBId).toBe('bot-0')
// Seed 2 (bot-6) vs Seed 7 (bot-1) in match 1
expect(r1[1].botAId).toBe('bot-6')
expect(r1[1].botBId).toBe('bot-1')
})
it('all round-1 matches are pending (no byes)', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const r1 = bracket.matches.filter(m => m.round === 1)
expect(r1.every(m => m.status === 'pending')).toBe(true)
})
})
describe('bracket generation — 4 bots in size-8 bracket (with byes)', () => {
it('generates 4 round-1 matches, all byes auto-advance', () => {
const { tid } = createAndFill(8, 4)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const r1 = bracket.matches.filter(m => m.round === 1)
expect(r1).toHaveLength(4)
// 4 bots fill seeded slots [0..3], slots [4..7] are null.
// Matches pair seeded[i] vs seeded[7-i], so every match is bot vs null = bye.
// All 4 round-1 matches should be finished (auto-advanced).
const byeMatches = r1.filter(m => m.status === 'finished')
expect(byeMatches).toHaveLength(4)
// Round 2 should already be generated with the 4 winners
const r2 = bracket.matches.filter(m => m.round === 2)
expect(r2.length).toBeGreaterThanOrEqual(2)
})
it('bye matches have a winner set', () => {
const { tid } = createAndFill(8, 4)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const byeMatches = bracket.matches.filter(m => m.round === 1 && m.status === 'finished')
for (const m of byeMatches) {
expect(m.winnerId).toBeTruthy()
// Winner should be the non-null bot
if (m.botAId && !m.botBId) expect(m.winnerId).toBe(m.botAId)
if (m.botBId && !m.botAId) expect(m.winnerId).toBe(m.botBId)
}
})
})
describe('bracket generation — 16 bots', () => {
it('generates 8 round-1 matches for full 16-bot bracket', () => {
const { tid } = createAndFill(16, 16)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const r1 = bracket.matches.filter(m => m.round === 1)
expect(r1).toHaveLength(8)
expect(r1.every(m => m.status === 'pending')).toBe(true)
})
})
// ---------------------------------------------------------------------------
// getPendingMatches & linkFightToMatch
// ---------------------------------------------------------------------------
describe('getPendingMatches', () => {
it('returns matches where both bots are present', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const pending = getPendingMatches(tid)
expect(pending.length).toBe(4)
for (const m of pending) {
expect(m.botAId).toBeTruthy()
expect(m.botBId).toBeTruthy()
expect(m.status).toBe('pending')
}
})
})
describe('linkFightToMatch', () => {
it('sets fight ID and status to live', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const pending = getPendingMatches(tid)
const match = pending[0]
safeLink(match.id, 'fight-xyz', match.botAId!, match.botBId!)
const updated = testDb.db.select().from(testDb.schema.tournamentMatches)
.where(eq(testDb.schema.tournamentMatches.id, match.id))
.get()!
expect(updated.fightId).toBe('fight-xyz')
expect(updated.status).toBe('live')
})
})
// ---------------------------------------------------------------------------
// onFightFinished — elimination & round progression
// ---------------------------------------------------------------------------
describe('onFightFinished — elimination logic', () => {
it('marks loser as eliminated', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const pending = getPendingMatches(tid)
const match = pending[0]
safeLink(match.id, 'fight-elim-1', match.botAId!, match.botBId!)
onFightFinished('fight-elim-1', match.botAId!)
// Loser (botB) should be eliminated
const entry = testDb.db.select().from(testDb.schema.tournamentEntries)
.where(and(
eq(testDb.schema.tournamentEntries.tournamentId, tid),
eq(testDb.schema.tournamentEntries.botId, match.botBId!),
))
.get()!
// SQLite stores boolean as 0/1 via raw query; drizzle may return number
expect(entry.eliminated).toBeTruthy()
})
it('updates match with winner and finished status', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const pending = getPendingMatches(tid)
const match = pending[0]
safeLink(match.id, 'fight-elim-2', match.botAId!, match.botBId!)
onFightFinished('fight-elim-2', match.botAId!)
const updated = testDb.db.select().from(testDb.schema.tournamentMatches)
.where(eq(testDb.schema.tournamentMatches.id, match.id))
.get()!
expect(updated.winnerId).toBe(match.botAId)
expect(updated.status).toBe('finished')
})
it('ignores draw (null winnerId)', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const pending = getPendingMatches(tid)
safeLink(pending[0].id, 'fight-draw', pending[0].botAId!, pending[0].botBId!)
// onFightFinished early-returns on falsy winnerId, so no-op
onFightFinished('fight-draw', null as unknown as string)
})
it('ignores non-tournament fights', () => {
// No tournament context — should not throw
onFightFinished('random-fight-id', 'some-bot')
})
})
// ---------------------------------------------------------------------------
// round progression — full tournament lifecycle
// ---------------------------------------------------------------------------
describe('round progression', () => {
it('advances to round 2 when all round-1 matches finish', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const pending = getPendingMatches(tid)
expect(pending).toHaveLength(4)
// Finish all round 1 matches — botA always wins
for (const match of pending) {
safeLink(match.id, `fight-r1-${match.matchIndex}`, match.botAId!, match.botBId!)
onFightFinished(`fight-r1-${match.matchIndex}`, match.botAId!)
}
const bracket = getTournamentBracket(tid)!
expect(bracket.tournament.currentRound).toBe(2)
// Round 2 should have 2 matches
const r2 = bracket.matches.filter(m => m.round === 2)
expect(r2).toHaveLength(2)
})
it('full 8-bot tournament completes in 3 rounds (8 -> 4 -> 2 -> 1)', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
// Round 1: 4 matches
let pending = getPendingMatches(tid)
expect(pending).toHaveLength(4)
for (const m of pending) {
safeLink(m.id, `fight-${m.round}-${m.matchIndex}`, m.botAId!, m.botBId!)
onFightFinished(`fight-${m.round}-${m.matchIndex}`, m.botAId!)
}
// Round 2: 2 matches
pending = getPendingMatches(tid)
expect(pending).toHaveLength(2)
for (const m of pending) {
safeLink(m.id, `fight-${m.round}-${m.matchIndex}`, m.botAId!, m.botBId!)
onFightFinished(`fight-${m.round}-${m.matchIndex}`, m.botAId!)
}
// Round 3 (final): 1 match
pending = getPendingMatches(tid)
expect(pending).toHaveLength(1)
const finalMatch = pending[0]
safeLink(finalMatch.id, 'fight-final', finalMatch.botAId!, finalMatch.botBId!)
onFightFinished('fight-final', finalMatch.botAId!)
// Tournament should be finished
const bracket = getTournamentBracket(tid)!
expect(bracket.tournament.status).toBe('finished')
expect(bracket.tournament.finishedAt).toBeTruthy()
})
it('partial round completion does not advance', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const pending = getPendingMatches(tid)
// Only finish 2 out of 4 matches
for (let i = 0; i < 2; i++) {
safeLink(pending[i].id, `fight-partial-${i}`, pending[i].botAId!, pending[i].botBId!)
onFightFinished(`fight-partial-${i}`, pending[i].botAId!)
}
const bracket = getTournamentBracket(tid)!
// Still in round 1 — not all matches finished
expect(bracket.tournament.currentRound).toBe(1)
// No round 2 matches generated yet
const r2 = bracket.matches.filter(m => m.round === 2)
expect(r2).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// getTournamentBracket
// ---------------------------------------------------------------------------
describe('getTournamentBracket', () => {
it('returns null for unknown tournament', () => {
expect(getTournamentBracket('fake-id')).toBeNull()
})
it('includes bot names in match data', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const r1 = bracket.matches.filter(m => m.round === 1)
for (const m of r1) {
if (m.botAId) expect(m.botAName).toBeTruthy()
if (m.botBId) expect(m.botBName).toBeTruthy()
}
})
it('entries show seed numbers after start', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
const seeds = bracket.entries.map(e => e.seed).sort((a, b) => a - b)
expect(seeds).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
})
it('highest ELO gets seed 1', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
const bracket = getTournamentBracket(tid)!
// bot-7 has highest ELO (1200 + 7*100 = 1900)
const topSeed = bracket.entries.find(e => e.seed === 1)!
expect(topSeed.botId).toBe('bot-7')
})
it('tracks eliminated status', () => {
const { tid } = createAndFill(8, 8)
startTournament(tid)
// Before any fights, nobody eliminated
let bracket = getTournamentBracket(tid)!
expect(bracket.entries.every(e => !e.eliminated)).toBe(true)
// Finish one match
const pending = getPendingMatches(tid)
safeLink(pending[0].id, 'fight-track', pending[0].botAId!, pending[0].botBId!)
onFightFinished('fight-track', pending[0].botAId!)
bracket = getTournamentBracket(tid)!
const eliminated = bracket.entries.filter(e => e.eliminated)
expect(eliminated).toHaveLength(1)
expect(eliminated[0].botId).toBe(pending[0].botBId)
})
})