544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
/**
|
|||
|
|
* 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)
|
||
|
|
})
|
||
|
|
})
|