diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 3a452c6ae..3cd228f29 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -2,7 +2,8 @@ import * as R from "remeda"; import { describe, expect, it } from "vitest"; import invariant from "../../../utils/invariant"; import * as Engine from "./engine"; -import { EngineBracket } from "./engine/test-utils"; +import { createResolved } from "./engine/create"; +import type { BracketData, MatchData } from "./engine/types"; import { Tournament } from "./Tournament"; import { PADDLING_POOL_255 } from "./tests/mocks"; import { LOW_INK_DECEMBER_2024 } from "./tests/mocks-li"; @@ -228,9 +229,7 @@ describe("round robin standings - dropped out teams", () => { skipMatchups?: string[]; forfeitMatchups?: string[]; } = {}) => { - const bracket = new EngineBracket(); - - bracket.create({ + let data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4], settings: { @@ -244,25 +243,25 @@ describe("round robin standings - dropped out teams", () => { winnerScore: number, loserScore: number, ) => { - const match = bracket.match(matchId); + const match = matchById(data, matchId); const winnerIsOpp1 = match.opponent1?.id === winnerId; - bracket.updateMatch({ - id: match.id, + data = Engine.reportResult(data, { + matchId, opponent1: { score: winnerIsOpp1 ? winnerScore : loserScore }, opponent2: { score: winnerIsOpp1 ? loserScore : winnerScore }, winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", - }); + }).data; }; // Mimics endDroppedTeamMatches: sets a winner via result only, with no // score recorded on either side (the match was never actually played). const forfeitMatch = (matchId: number, winnerId: number) => { - const match = bracket.match(matchId); - bracket.updateMatch({ - id: match.id, + const match = matchById(data, matchId); + data = Engine.reportResult(data, { + matchId, winnerSide: match.opponent1?.id === winnerId ? "opponent1" : "opponent2", - }); + }).data; }; // Team 1 beat everyone, team 2 beat 3 and 4, team 3 beat 4. @@ -274,7 +273,7 @@ describe("round robin standings - dropped out teams", () => { "2-4": 2, "3-4": 3, }; - for (const match of bracket.matches()) { + for (const match of data.match) { const a = match.opponent1!.id as number; const b = match.opponent2!.id as number; const key = a < b ? `${a}-${b}` : `${b}-${a}`; @@ -288,8 +287,6 @@ describe("round robin standings - dropped out teams", () => { } } - const data = bracket.data!; - return testTournament({ ctx: { settings: { @@ -393,9 +390,7 @@ describe("round robin standings - dropped out teams", () => { describe("round robin A/B divisions standings", () => { const abDivisionsTournament = () => { - const bracket = new EngineBracket(); - - bracket.create({ + let data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4], abDivisions: [0, 1, 0, 1], @@ -411,14 +406,14 @@ describe("round robin A/B divisions standings", () => { winnerScore: number, loserScore: number, ) => { - const match = bracket.match(matchId); + const match = matchById(data, matchId); const winnerIsOpp1 = match.opponent1?.id === winnerId; - bracket.updateMatch({ - id: match.id, + data = Engine.reportResult(data, { + matchId, opponent1: { score: winnerIsOpp1 ? winnerScore : loserScore }, opponent2: { score: winnerIsOpp1 ? loserScore : winnerScore }, winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", - }); + }).data; }; const winnerByMatchup: Record = { @@ -427,7 +422,7 @@ describe("round robin A/B divisions standings", () => { "2-3": 2, "3-4": 3, }; - for (const match of bracket.matches()) { + for (const match of data.match) { const a = match.opponent1!.id as number; const b = match.opponent2!.id as number; const key = a < b ? `${a}-${b}` : `${b}-${a}`; @@ -437,8 +432,6 @@ describe("round robin A/B divisions standings", () => { setResult(match.id, winnerId, 2, loserScore); } - const data = bracket.data!; - return testTournament({ ctx: { settings: { @@ -510,26 +503,24 @@ describe("single elimination standings - third place match", () => { }: { thirdPlaceMatchReported: boolean; }) => { - const bracket = new EngineBracket(); - - bracket.create({ + let data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4], settings: { consolationFinal: true }, }); const reportLowerTeamIdAsWinner = (matchId: number) => { - bracket.updateMatch({ - id: matchId, + data = Engine.reportResult(data, { + matchId, opponent1: { score: 2 }, opponent2: { score: 0 }, winnerSide: "opponent1", - }); + }).data; }; - const semifinals = bracket - .matches() - .filter((match) => match.opponent1?.id && match.opponent2?.id); + const semifinals = data.match.filter( + (match) => match.opponent1?.id && match.opponent2?.id, + ); invariant(semifinals.length === 2, "Expected two semifinal matches"); const semifinalLoserIds: number[] = []; @@ -542,11 +533,11 @@ describe("single elimination standings - third place match", () => { let thirdPlaceLoserId: number | undefined; if (thirdPlaceMatchReported) { const thirdPlaceGroupId = Math.max( - ...bracket.groups().map((group) => group.id), + ...data.group.map((group) => group.id), + ); + const thirdPlaceMatch = data.match.find( + (match) => match.groupId === thirdPlaceGroupId, ); - const thirdPlaceMatch = bracket - .matches() - .find((match) => match.groupId === thirdPlaceGroupId); invariant(thirdPlaceMatch, "Third place match not found"); thirdPlaceWinnerId = thirdPlaceMatch.opponent1!.id!; thirdPlaceLoserId = thirdPlaceMatch.opponent2!.id!; @@ -567,7 +558,7 @@ describe("single elimination standings - third place match", () => { ], }, }, - data: bracket.data!, + data, }); return { tournament, thirdPlaceWinnerId, thirdPlaceLoserId }; @@ -600,47 +591,20 @@ describe("single elimination standings - third place match", () => { }); }); -const reportLowerIdWinner = (bracket: EngineBracket, matchId: number) => { - const match = bracket.match(matchId); - const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!; - bracket.updateMatch({ - id: matchId, - opponent1: { score: opponent1Lower ? 2 : 0 }, - opponent2: { score: opponent1Lower ? 0 : 2 }, - winnerSide: opponent1Lower ? "opponent1" : "opponent2", - }); -}; - -const readyMatches = ( - bracket: EngineBracket, - predicate: (match: ReturnType[number]) => boolean, -) => - bracket - .matches() - .filter( - (match) => - predicate(match) && - match.opponent1?.id != null && - match.opponent2?.id != null && - match.winnerSide == null, - ); - describe("single elimination standings - projected ties", () => { // Two semifinal losers tie for 3rd (no consolation final). Reports only one // semifinal so the other is still in progress, mirroring the projected // standings bug where the finished team is shown one placement too low. const partialSingleEliminationTournament = () => { - const bracket = new EngineBracket(); - - bracket.create({ + let data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4], settings: {}, }); - const semifinals = bracket - .matches() - .filter((match) => match.opponent1?.id && match.opponent2?.id); + const semifinals = data.match.filter( + (match) => match.opponent1?.id && match.opponent2?.id, + ); invariant(semifinals.length === 2, "Expected two semifinal matches"); const decided = semifinals[0]; @@ -648,7 +612,7 @@ describe("single elimination standings - projected ties", () => { decided.opponent1!.id!, decided.opponent2!.id!, ); - reportLowerIdWinner(bracket, decided.id); + data = reportLowerIdWinner(data, decided.id); const tournament = testTournament({ ctx: { @@ -664,7 +628,7 @@ describe("single elimination standings - projected ties", () => { ], }, }, - data: bracket.data!, + data, }); return { tournament, decidedLoserId }; @@ -687,47 +651,42 @@ describe("double elimination standings - projected ties", () => { // losers round 2 matches so its loser should already project to tied 5th // while the sibling match is still unfinished. const partialDoubleEliminationTournament = () => { - const bracket = new EngineBracket(); - - bracket.create({ + let data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: {}, }); const groupId = (number: number) => - bracket.groups().find((g) => g.number === number)!.id; + data.group.find((group) => group.number === number)!.id; const winnersGroupId = groupId(1); const losersGroupId = groupId(2); const losersRoundId = (number: number) => - bracket - .rounds() - .find((r) => r.groupId === losersGroupId && r.number === number)!.id; + data.round.find( + (round) => round.groupId === losersGroupId && round.number === number, + )!.id; // play out the entire winners bracket so all losers feed in - let winnersReady = readyMatches( - bracket, - (m) => m.groupId === winnersGroupId, - ); + let winnersReady = readyMatches(data, (m) => m.groupId === winnersGroupId); while (winnersReady.length) { for (const match of winnersReady) { - reportLowerIdWinner(bracket, match.id); + data = reportLowerIdWinner(data, match.id); } - winnersReady = readyMatches(bracket, (m) => m.groupId === winnersGroupId); + winnersReady = readyMatches(data, (m) => m.groupId === winnersGroupId); } // losers round 1: both matches -> two teams eliminated, tied 7th/8th for (const match of readyMatches( - bracket, + data, (m) => m.roundId === losersRoundId(1), )) { - reportLowerIdWinner(bracket, match.id); + data = reportLowerIdWinner(data, match.id); } // losers round 2: report only one of the two matches const losersRound2 = readyMatches( - bracket, + data, (m) => m.roundId === losersRoundId(2), ); invariant(losersRound2.length === 2, "Expected two losers round 2 matches"); @@ -741,7 +700,7 @@ describe("double elimination standings - projected ties", () => { losersRound2[1].opponent1!.id, losersRound2[1].opponent2!.id, ]; - reportLowerIdWinner(bracket, decided.id); + data = reportLowerIdWinner(data, decided.id); const tournament = testTournament({ ctx: { @@ -757,7 +716,7 @@ describe("double elimination standings - projected ties", () => { ], }, }, - data: bracket.data!, + data, }); return { tournament, decidedLoserId, stillPlayingTeamIds }; @@ -784,3 +743,35 @@ describe("double elimination standings - projected ties", () => { } }); }); + +function reportLowerIdWinner(data: BracketData, matchId: number): BracketData { + const match = matchById(data, matchId); + const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!; + + return Engine.reportResult(data, { + matchId, + opponent1: { score: opponent1Lower ? 2 : 0 }, + opponent2: { score: opponent1Lower ? 0 : 2 }, + winnerSide: opponent1Lower ? "opponent1" : "opponent2", + }).data; +} + +function readyMatches( + data: BracketData, + predicate: (match: MatchData) => boolean, +) { + return data.match.filter( + (match) => + predicate(match) && + match.opponent1?.id != null && + match.opponent2?.id != null && + match.winnerSide == null, + ); +} + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts index 24e7bfaf3..97f6e4f52 100644 --- a/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts @@ -1,44 +1,40 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { EngineBracket } from "../test-utils"; - -const bracket = new EngineBracket(); +import { describe, expect, test } from "vitest"; +import { createResolved } from "./index"; describe("Create double elimination stage", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should create a double elimination stage", () => { - bracket.create({ + const data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], settings: {}, }); - const stage = bracket.stage(); - expect(stage.type).toBe("double_elimination"); + expect(data.stage[0].type).toBe("double_elimination"); - expect(bracket.groups().length).toBe(3); - expect(bracket.rounds().length).toBe(4 + 6 + 2); - expect(bracket.matches().length).toBe(31); + expect(data.group.length).toBe(3); + expect(data.round.length).toBe(4 + 6 + 2); + expect(data.match.length).toBe(31); }); test("should create a tournament with 256+ tournaments", () => { - bracket.create({ - type: "double_elimination", - seeding: Array.from({ length: 256 }, (_, i) => i + 1), - }); + expect(() => + createResolved({ + type: "double_elimination", + seeding: Array.from({ length: 256 }, (_, i) => i + 1), + settings: {}, + }), + ).not.toThrow(); }); test("should create a tournament with a double grand final", () => { - bracket.create({ + const data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: {}, }); - expect(bracket.groups().length).toBe(3); - expect(bracket.rounds().length).toBe(3 + 4 + 2); - expect(bracket.matches().length).toBe(15); + expect(data.group.length).toBe(3); + expect(data.round.length).toBe(3 + 4 + 2); + expect(data.match.length).toBe(15); }); }); diff --git a/app/features/tournament-bracket/core/engine/create/round-robin.test.ts b/app/features/tournament-bracket/core/engine/create/round-robin.test.ts index cf74e913f..03a8ba263 100644 --- a/app/features/tournament-bracket/core/engine/create/round-robin.test.ts +++ b/app/features/tournament-bracket/core/engine/create/round-robin.test.ts @@ -1,41 +1,32 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { EngineBracket } from "../test-utils"; - -const bracket = new EngineBracket(); +import { describe, expect, test } from "vitest"; +import type { BracketData, MatchData } from "../types"; +import { createResolved } from "./index"; describe("Create a round-robin stage", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should create a round-robin stage", () => { - const example = { - type: "round_robin" as const, + const data = createResolved({ + type: "round_robin", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: { groupCount: 2 }, - }; + }); - bracket.create(example); + expect(data.stage[0].type).toBe("round_robin"); - const stage = bracket.stage(); - expect(stage.type).toBe(example.type); - - expect(bracket.groups().length).toBe(2); - expect(bracket.rounds().length).toBe(6); - expect(bracket.matches().length).toBe(12); + expect(data.group.length).toBe(2); + expect(data.round.length).toBe(6); + expect(data.match.length).toBe(12); }); test("should drop empty slots instead of creating BYE matches", () => { - bracket.create({ + const data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4, 5, null, null, null], settings: { groupCount: 2 }, }); // 5 teams in 2 groups -> groups of 3 and 2 with no BYE matches at all. - const matches = bracket.matches(); - expect(matches.length).toBe(4); - for (const match of matches) { + expect(data.match.length).toBe(4); + for (const match of data.match) { expect(match.opponent1?.id).not.toBeNull(); expect(match.opponent2?.id).not.toBeNull(); } @@ -45,24 +36,25 @@ describe("Create a round-robin stage", () => { // 5 teams in 2 groups -> groups of 3 and 2. The 2-team group must be a // clean single-round single-match group, not padded with BYE-only rounds // that strand the real match in a later round. - bracket.create({ + const data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4, 5], settings: { groupCount: 2 }, }); - const isRealMatch = (match: { - opponent1: { id: number | null } | null; - opponent2: { id: number | null } | null; - }) => match.opponent1?.id != null && match.opponent2?.id != null; + const shortGroup = data.group.find( + (group) => + data.match.filter( + (match) => match.groupId === group.id && isRealMatch(match), + ).length === 1, + )!; - const shortGroup = bracket.groups().find((group) => { - const matches = bracket.matches({ groupId: group.id }); - return matches.filter(isRealMatch).length === 1; - })!; - - const rounds = bracket.rounds({ groupId: shortGroup.id }); - const matches = bracket.matches({ groupId: shortGroup.id }); + const rounds = data.round.filter( + (round) => round.groupId === shortGroup.id, + ); + const matches = data.match.filter( + (match) => match.groupId === shortGroup.id, + ); const realMatch = matches.find(isRealMatch)!; const realMatchRound = rounds.find( (round) => round.id === realMatch.roundId, @@ -75,7 +67,7 @@ describe("Create a round-robin stage", () => { }); test("should create a round-robin stage split across multiple groups", () => { - bracket.create({ + const data = createResolved({ type: "round_robin", seeding: Array.from({ length: 16 }, (_, i) => i + 1), settings: { @@ -83,13 +75,13 @@ describe("Create a round-robin stage", () => { }, }); - expect(bracket.groups().length).toBe(4); - expect(bracket.rounds().length).toBe(4 * 3); - expect(bracket.matches().length).toBe(4 * 3 * 2); + expect(data.group.length).toBe(4); + expect(data.round.length).toBe(4 * 3); + expect(data.match.length).toBe(4 * 3 * 2); }); test("should order the groups with snake seeding", () => { - bracket.create({ + const data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: { @@ -97,22 +89,23 @@ describe("Create a round-robin stage", () => { }, }); - expect(bracket.match(0).opponent1?.id).toBe(1); - expect(bracket.match(0).opponent2?.id).toBe(8); + expect(matchById(data, 0).opponent1?.id).toBe(1); + expect(matchById(data, 0).opponent2?.id).toBe(8); }); test("should throw if no group count given", () => { expect(() => - bracket.create({ + createResolved({ type: "round_robin", seeding: [1, 2, 3, 4], + settings: {}, }), ).toThrow("You must specify a group count for round-robin stages."); }); test("should throw if the group count is not strictly positive", () => { expect(() => - bracket.create({ + createResolved({ type: "round_robin", seeding: [1, 2, 3, 4], settings: { @@ -130,7 +123,7 @@ describe("Create a round-robin stage", () => { | 1 )[]; - bracket.create({ + const data = createResolved({ type: "round_robin", seeding, abDivisions, @@ -140,15 +133,15 @@ describe("Create a round-robin stage", () => { }, }); - expect(bracket.groups().length).toBe(1); - expect(bracket.rounds().length).toBe(6); - expect(bracket.matches().length).toBe(36); + expect(data.group.length).toBe(1); + expect(data.round.length).toBe(6); + expect(data.match.length).toBe(36); const divisionAIds = new Set([1, 3, 5, 7, 9, 11]); const divisionBIds = new Set([2, 4, 6, 8, 10, 12]); const pairings = new Set(); - for (const match of bracket.matches()) { + for (const match of data.match) { const aId = match.opponent1?.id; const bId = match.opponent2?.id; @@ -165,7 +158,7 @@ describe("Create a round-robin stage", () => { test("throws when A/B divisions are requested but abDivisions is missing", () => { expect(() => - bracket.create({ + createResolved({ type: "round_robin", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], settings: { @@ -177,10 +170,9 @@ describe("Create a round-robin stage", () => { }); test("creates an A/B divisions round-robin with uneven (±1) divisions and a single group", () => { - const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - bracket.create({ + const data = createResolved({ type: "round_robin", - seeding, + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], settings: { groupCount: 1, @@ -188,14 +180,14 @@ describe("Create a round-robin stage", () => { }, }); - expect(bracket.groups().length).toBe(1); - expect(bracket.rounds().length).toBe(6); - expect(bracket.matches().length).toBe(30); + expect(data.group.length).toBe(1); + expect(data.round.length).toBe(6); + expect(data.match.length).toBe(30); }); test("throws when A/B divisions are uneven with multiple groups", () => { expect(() => - bracket.create({ + createResolved({ type: "round_robin", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], @@ -207,3 +199,14 @@ describe("Create a round-robin stage", () => { ).toThrow("Uneven A/B divisions are only supported with a single group."); }); }); + +function isRealMatch(match: MatchData) { + return match.opponent1?.id != null && match.opponent2?.id != null; +} + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts index ec449710e..0014654c4 100644 --- a/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts @@ -1,85 +1,77 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { EngineBracket } from "../test-utils"; - -const bracket = new EngineBracket(); +import { describe, expect, test } from "vitest"; +import type { BracketData } from "../types"; +import { createResolved } from "./index"; describe("Create single elimination stage", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should create a single elimination stage", () => { - const example = { - type: "single_elimination" as const, + const data = createResolved({ + type: "single_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], settings: {}, - }; + }); - bracket.create(example); + expect(data.stage[0].type).toBe("single_elimination"); - const stage = bracket.stage(); - expect(stage.type).toBe(example.type); - - expect(bracket.groups().length).toBe(1); - expect(bracket.rounds().length).toBe(4); - expect(bracket.matches().length).toBe(15); + expect(data.group.length).toBe(1); + expect(data.round.length).toBe(4); + expect(data.match.length).toBe(15); }); test("should create a single elimination stage with BYEs", () => { - bracket.create({ + const data = createResolved({ type: "single_elimination", seeding: [1, null, 3, 4, null, null, 7, 8], settings: {}, }); - expect(bracket.match(4).opponent1?.id).toBe(null); - expect(bracket.match(4).opponent2?.id).toBe(4); - expect(bracket.match(5).opponent1?.id).toBe(7); - expect(bracket.match(5).opponent2?.id).toBe(3); + expect(matchById(data, 4).opponent1?.id).toBe(null); + expect(matchById(data, 4).opponent2?.id).toBe(4); + expect(matchById(data, 5).opponent1?.id).toBe(7); + expect(matchById(data, 5).opponent2?.id).toBe(3); }); test("should create a single elimination stage with consolation final", () => { - bracket.create({ + const data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: { consolationFinal: true }, }); - expect(bracket.groups().length).toBe(2); - expect(bracket.rounds().length).toBe(4); - expect(bracket.matches().length).toBe(8); + expect(data.group.length).toBe(2); + expect(data.round.length).toBe(4); + expect(data.match.length).toBe(8); }); test("should create a single elimination stage with consolation final and BYEs", () => { - bracket.create({ + const data = createResolved({ type: "single_elimination", seeding: [null, null, null, 4, 5, 6, 7, 8], settings: { consolationFinal: true }, }); - expect(bracket.match(4).opponent1?.id).toBe(8); - expect(bracket.match(4).opponent2?.id).toBe(null); + expect(matchById(data, 4).opponent1?.id).toBe(8); + expect(matchById(data, 4).opponent2?.id).toBe(null); // Consolation final - expect(bracket.match(7).opponent1?.id).toBe(null); - expect(bracket.match(7).opponent2?.id).toBe(null); + expect(matchById(data, 7).opponent1?.id).toBe(null); + expect(matchById(data, 7).opponent2?.id).toBe(null); }); test("should create a single elimination stage with Bo3 matches", () => { - bracket.create({ + const data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: {}, }); - expect(bracket.groups().length).toBe(1); - expect(bracket.rounds().length).toBe(3); - expect(bracket.matches().length).toBe(7); + expect(data.group.length).toBe(1); + expect(data.round.length).toBe(3); + expect(data.match.length).toBe(7); }); test("should throw if the seeding has duplicate participants", () => { expect(() => - bracket.create({ + createResolved({ type: "single_elimination", seeding: [ 1, @@ -87,7 +79,15 @@ describe("Create single elimination stage", () => { 3, 4, ], + settings: {}, }), ).toThrow("The seeding has a duplicate participant."); }); }); + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/general.test.ts b/app/features/tournament-bracket/core/engine/general.test.ts index fdfd762b6..85962eeac 100644 --- a/app/features/tournament-bracket/core/engine/general.test.ts +++ b/app/features/tournament-bracket/core/engine/general.test.ts @@ -1,55 +1,49 @@ import { beforeEach, describe, expect, test } from "vitest"; import { createResolved } from "./create"; import * as Engine from "./index"; -import { EngineBracket } from "./test-utils"; - -const bracket = new EngineBracket(); +import type { BracketData } from "./types"; describe("BYE handling", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should propagate BYEs through the brackets", () => { - bracket.create({ + const data = createResolved({ type: "double_elimination", seeding: [1, null, null, null], settings: {}, }); - expect(bracket.match(2).opponent1?.id).toBe(1); - expect(bracket.match(2).opponent2).toBe(null); + expect(matchById(data, 2).opponent1?.id).toBe(1); + expect(matchById(data, 2).opponent2).toBe(null); - expect(bracket.match(3).opponent1).toBe(null); - expect(bracket.match(3).opponent2).toBe(null); + expect(matchById(data, 3).opponent1).toBe(null); + expect(matchById(data, 3).opponent2).toBe(null); - expect(bracket.match(4).opponent1).toBe(null); - expect(bracket.match(4).opponent2).toBe(null); + expect(matchById(data, 4).opponent1).toBe(null); + expect(matchById(data, 4).opponent2).toBe(null); - expect(bracket.match(5).opponent1?.id).toBe(1); - expect(bracket.match(5).opponent2).toBe(null); + expect(matchById(data, 5).opponent1?.id).toBe(1); + expect(matchById(data, 5).opponent2).toBe(null); }); test("should handle incomplete seeding during creation", () => { - bracket.create({ + const data = createResolved({ type: "double_elimination", seeding: [1, 2, null, null], settings: {}, }); - expect(bracket.match(0).opponent1?.id).toBe(1); - expect(bracket.match(0).opponent2).toBe(null); + expect(matchById(data, 0).opponent1?.id).toBe(1); + expect(matchById(data, 0).opponent2).toBe(null); - expect(bracket.match(1).opponent1?.id).toBe(2); - expect(bracket.match(1).opponent2).toBe(null); + expect(matchById(data, 1).opponent1?.id).toBe(2); + expect(matchById(data, 1).opponent2).toBe(null); }); }); describe("Position checks", () => { - beforeEach(() => { - bracket.reset(); + let data: BracketData; - bracket.create({ + beforeEach(() => { + data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: {}, @@ -57,72 +51,70 @@ describe("Position checks", () => { }); test("should not have a position when we don't need the origin of a participant", () => { - const matchFromWbRound2 = bracket.match(4); + const matchFromWbRound2 = matchById(data, 4); expect(matchFromWbRound2.opponent1?.position).toBe(undefined); expect(matchFromWbRound2.opponent2?.position).toBe(undefined); - const matchFromLbRound2 = bracket.match(9); + const matchFromLbRound2 = matchById(data, 9); expect(matchFromLbRound2.opponent2?.position).toBe(undefined); - const matchFromGrandFinal = bracket.match(13); + const matchFromGrandFinal = matchById(data, 13); expect(matchFromGrandFinal.opponent1?.position).toBe(undefined); }); test("should have a position where we need the origin of a participant", () => { - const matchFromWbRound1 = bracket.match(0); + const matchFromWbRound1 = matchById(data, 0); expect(matchFromWbRound1.opponent1?.position).toBe(1); expect(matchFromWbRound1.opponent2?.position).toBe(8); - const matchFromLbRound1 = bracket.match(7); + const matchFromLbRound1 = matchById(data, 7); expect(matchFromLbRound1.opponent1?.position).toBe(1); expect(matchFromLbRound1.opponent2?.position).toBe(2); - const matchFromLbRound2 = bracket.match(9); + const matchFromLbRound2 = matchById(data, 9); expect(matchFromLbRound2.opponent1?.position).toBe(2); - const matchFromGrandFinal = bracket.match(13); + const matchFromGrandFinal = matchById(data, 13); expect(matchFromGrandFinal.opponent2?.position).toBe(1); }); }); describe("Special cases", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should pad the seeding with BYEs to the next power of two", () => { - bracket.create({ + const data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4, 5, 6, 7], settings: {}, }); - expect(bracket.match(0).opponent1?.id).toBe(1); - expect(bracket.match(0).opponent2).toBe(null); + expect(matchById(data, 0).opponent1?.id).toBe(1); + expect(matchById(data, 0).opponent2).toBe(null); }); test("should throw if the participant count of a stage is less than two", () => { expect(() => - bracket.create({ + createResolved({ type: "single_elimination", seeding: [], + settings: {}, }), ).toThrow("Impossible to create a stage with less than 2 participants."); expect(() => - bracket.create({ + createResolved({ type: "single_elimination", seeding: [1], + settings: {}, }), ).toThrow("Impossible to create a stage with less than 2 participants."); }); }); describe("Seeding and ordering in elimination", () => { - beforeEach(() => { - bracket.reset(); + let data: BracketData; - bracket.create({ + beforeEach(() => { + data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], settings: {}, @@ -130,108 +122,88 @@ describe("Seeding and ordering in elimination", () => { }); test("should have the good orderings everywhere", () => { - const firstRoundMatchWB = bracket.match(0); + const firstRoundMatchWB = matchById(data, 0); expect(firstRoundMatchWB.opponent1?.position).toBe(1); expect(firstRoundMatchWB.opponent2?.position).toBe(16); - const firstRoundMatchLB = bracket.match(15); + const firstRoundMatchLB = matchById(data, 15); expect(firstRoundMatchLB.opponent1?.position).toBe(1); expect(firstRoundMatchLB.opponent2?.position).toBe(2); - const secondRoundMatchLB = bracket.match(19); + const secondRoundMatchLB = matchById(data, 19); expect(secondRoundMatchLB.opponent1?.position).toBe(2); - const secondRoundSecondMatchLB = bracket.match(20); + const secondRoundSecondMatchLB = matchById(data, 20); expect(secondRoundSecondMatchLB.opponent1?.position).toBe(1); - const fourthRoundMatchLB = bracket.match(25); + const fourthRoundMatchLB = matchById(data, 25); expect(fourthRoundMatchLB.opponent1?.position).toBe(2); - const finalRoundMatchLB = bracket.match(28); + const finalRoundMatchLB = matchById(data, 28); expect(finalRoundMatchLB.opponent1?.position).toBe(1); }); }); describe("Reset match", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should reset results of a match", () => { // Seeds 1 and 2 are placed into the same first-round match (positions 1 // and 8) so that match 0 is a real two-team match under the default // space_between ordering, while the rest of the bracket is BYEs. - bracket.create({ + let data = createResolved({ type: "single_elimination", seeding: [1, null, null, null, null, null, null, 2], settings: {}, }); - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - let match = bracket.match(0); - expect(match.opponent1?.score).toBe(16); - expect(match.opponent2?.score).toBe(12); - expect(match.winnerSide).toBe("opponent1"); + expect(matchById(data, 0).opponent1?.score).toBe(16); + expect(matchById(data, 0).opponent2?.score).toBe(12); + expect(matchById(data, 0).winnerSide).toBe("opponent1"); - let semi1 = bracket.match(4); - expect(semi1.winnerSide).toBe("opponent1"); - expect(semi1.opponent2).toBe(null); + expect(matchById(data, 4).winnerSide).toBe("opponent1"); + expect(matchById(data, 4).opponent2).toBe(null); - let final = bracket.match(6); - expect(final.winnerSide).toBe("opponent1"); - expect(final.opponent2).toBe(null); + expect(matchById(data, 6).winnerSide).toBe("opponent1"); + expect(matchById(data, 6).opponent2).toBe(null); - bracket.resetMatchResults(0); // Score stays as is. + data = Engine.resetMatchResults(data, 0).data; // Score stays as is. - match = bracket.match(0); - expect(match.opponent1?.score).toBe(16); - expect(match.opponent2?.score).toBe(12); - expect(match.winnerSide).toBe(null); + expect(matchById(data, 0).opponent1?.score).toBe(16); + expect(matchById(data, 0).opponent2?.score).toBe(12); + expect(matchById(data, 0).winnerSide).toBe(null); - semi1 = bracket.match(4); - expect(semi1.winnerSide).toBe(null); - expect(semi1.opponent2).toBe(null); + expect(matchById(data, 4).winnerSide).toBe(null); + expect(matchById(data, 4).opponent2).toBe(null); - final = bracket.match(6); - expect(final.winnerSide).toBe(null); - expect(final.opponent2).toBe(null); + expect(matchById(data, 6).winnerSide).toBe(null); + expect(matchById(data, 6).opponent2).toBe(null); }); test("should throw when at least one of the following match is locked", () => { - bracket.create({ + let data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4], settings: {}, }); - bracket.updateMatch({ - id: 0, - opponent1: { score: 16 }, - opponent2: { score: 12 }, - winnerSide: "opponent1", - }); + for (const matchId of [0, 1, 2]) { + data = Engine.reportResult(data, { + matchId, + opponent1: { score: 16 }, + opponent2: { score: 12 }, + winnerSide: "opponent1", + }).data; + } - bracket.updateMatch({ - id: 1, - opponent1: { score: 16 }, - opponent2: { score: 12 }, - winnerSide: "opponent1", - }); - - bracket.updateMatch({ - id: 2, - opponent1: { score: 16 }, - opponent2: { score: 12 }, - winnerSide: "opponent1", - }); - - expect(() => bracket.resetMatchResults(0)).toThrow("The match is locked."); + expect(() => Engine.resetMatchResults(data, 0)).toThrow( + "The match is locked.", + ); }); }); @@ -281,3 +253,10 @@ describe("Engine data immutability", () => { expect(changedIds).not.toContain(1); // The other semi is untouched. }); }); + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts b/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts index ed792ad01..030b689db 100644 --- a/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts @@ -1,266 +1,285 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { EngineBracket } from "../test-utils"; - -const bracket = new EngineBracket(); +import { describe, expect, test } from "vitest"; +import { createResolved } from "../create"; +import * as Engine from "../index"; +import type { BracketData } from "../types"; describe("Previous and next match update in double elimination stage", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should end a match and determine next matches", () => { - bracket.create({ + let data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], settings: {}, }); - const before = bracket.match(8); // First match of WB round 2 + const before = matchById(data, 8); // First match of WB round 2 expect(before.opponent2?.id).toBeNull(); - bracket.updateMatch({ - id: 0, // First match of WB round 1 + data = Engine.reportResult(data, { + matchId: 0, // First match of WB round 1 opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - bracket.updateMatch({ - id: 1, // Second match of WB round 1 + data = Engine.reportResult(data, { + matchId: 1, // Second match of WB round 1 opponent1: { score: 13 }, opponent2: { score: 16 }, winnerSide: "opponent2", - }); + }).data; - bracket.updateMatch({ - id: 15, // First match of LB round 1 + data = Engine.reportResult(data, { + matchId: 15, // First match of LB round 1 opponent1: { score: 16 }, opponent2: { score: 10 }, winnerSide: "opponent1", - }); + }).data; expect( - bracket.match(8).opponent1?.id, // Determined opponent for WB round 2 - ).toBe(bracket.match(0).opponent1?.id); // Winner of first match round 1 + matchById(data, 8).opponent1?.id, // Determined opponent for WB round 2 + ).toBe(matchById(data, 0).opponent1?.id); // Winner of first match round 1 expect( - bracket.match(8).opponent2?.id, // Determined opponent for WB round 2 - ).toBe(bracket.match(1).opponent2?.id); // Winner of second match round 1 + matchById(data, 8).opponent2?.id, // Determined opponent for WB round 2 + ).toBe(matchById(data, 1).opponent2?.id); // Winner of second match round 1 expect( - bracket.match(15).opponent2?.id, // Determined opponent for LB round 1 - ).toBe(bracket.match(1).opponent1?.id); // Loser of second match round 1 + matchById(data, 15).opponent2?.id, // Determined opponent for LB round 1 + ).toBe(matchById(data, 1).opponent1?.id); // Loser of second match round 1 expect( - bracket.match(19).opponent2?.id, // Determined opponent for LB round 2 - ).toBe(bracket.match(0).opponent2?.id); // Loser of first match round 1 + matchById(data, 19).opponent2?.id, // Determined opponent for LB round 2 + ).toBe(matchById(data, 0).opponent2?.id); // Loser of first match round 1 }); test("should propagate winner when BYE is already in next match in loser bracket", () => { - bracket.create({ + let data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, null], settings: {}, }); - bracket.updateMatch({ - id: 1, // Second match of WB round 1 + data = Engine.reportResult(data, { + matchId: 1, // Second match of WB round 1 opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - const loserId = bracket.match(1).opponent2?.id; - let matchSemiLB = bracket.match(3); + const loserId = matchById(data, 1).opponent2?.id; - expect(matchSemiLB.opponent2?.id).toBe(loserId); - expect(matchSemiLB.winnerSide).toBe("opponent2"); - expect(bracket.matchStatus(3)).toBe("COMPLETED"); + expect(matchById(data, 3).opponent2?.id).toBe(loserId); + expect(matchById(data, 3).winnerSide).toBe("opponent2"); + expect(Engine.matchStatus(data, 3)).toBe("COMPLETED"); expect( - bracket.match(4).opponent2?.id, // Propagated winner in LB Final because of the BYE. + matchById(data, 4).opponent2?.id, // Propagated winner in LB Final because of the BYE. ).toBe(loserId); - bracket.resetMatchResults(1); // Second match of WB round 1 + data = Engine.resetMatchResults(data, 1).data; // Second match of WB round 1 - matchSemiLB = bracket.match(3); - expect(matchSemiLB.opponent2?.id).toBeNull(); - expect(matchSemiLB.winnerSide).toBeNull(); - expect(bracket.matchStatus(3)).toBe("PENDING"); + expect(matchById(data, 3).opponent2?.id).toBeNull(); + expect(matchById(data, 3).winnerSide).toBeNull(); + expect(Engine.matchStatus(data, 3)).toBe("PENDING"); - expect(bracket.match(4).opponent2?.id).toBeNull(); // Propagated winner is removed. + expect(matchById(data, 4).opponent2?.id).toBeNull(); // Propagated winner is removed. }); test("should determine matches in grand final", () => { - bracket.create({ + let data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4], settings: {}, }); - bracket.updateMatch({ - id: 0, // First match of WB round 1 + data = Engine.reportResult(data, { + matchId: 0, // First match of WB round 1 opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - bracket.updateMatch({ - id: 1, // Second match of WB round 1 + data = Engine.reportResult(data, { + matchId: 1, // Second match of WB round 1 opponent1: { score: 13 }, opponent2: { score: 16 }, winnerSide: "opponent2", - }); + }).data; - bracket.updateMatch({ - id: 2, // WB Final + data = Engine.reportResult(data, { + matchId: 2, // WB Final opponent1: { score: 16 }, opponent2: { score: 9 }, winnerSide: "opponent1", - }); + }).data; expect( - bracket.match(5).opponent1?.id, // Determined opponent for the grand final (round 1) - ).toBe(bracket.match(0).opponent1?.id); // Winner of WB Final + matchById(data, 5).opponent1?.id, // Determined opponent for the grand final (round 1) + ).toBe(matchById(data, 0).opponent1?.id); // Winner of WB Final - bracket.updateMatch({ - id: 3, // Only match of LB round 1 + data = Engine.reportResult(data, { + matchId: 3, // Only match of LB round 1 opponent1: { score: 12 }, opponent2: { score: 8 }, winnerSide: "opponent1", // Team 4 - }); + }).data; - bracket.updateMatch({ - id: 4, // LB Final + data = Engine.reportResult(data, { + matchId: 4, // LB Final opponent1: { score: 14 }, opponent2: { score: 7 }, winnerSide: "opponent1", // Team 3 - }); + }).data; expect( - bracket.match(5).opponent2?.id, // Determined opponent for the grand final (round 1) - ).toBe(bracket.match(1).opponent2?.id); // Winner of LB Final + matchById(data, 5).opponent2?.id, // Determined opponent for the grand final (round 1) + ).toBe(matchById(data, 1).opponent2?.id); // Winner of LB Final - bracket.updateMatch({ - id: 5, // Grand Final round 1 + data = Engine.reportResult(data, { + matchId: 5, // Grand Final round 1 opponent1: { score: 10 }, opponent2: { score: 16 }, winnerSide: "opponent2", // Team 3 - }); + }).data; expect( - bracket.match(6).opponent2?.id, // Determined opponent for the grand final (round 2) - ).toBe(bracket.match(1).opponent2?.id); // Winner of LB Final + matchById(data, 6).opponent2?.id, // Determined opponent for the grand final (round 2) + ).toBe(matchById(data, 1).opponent2?.id); // Winner of LB Final - expect(bracket.matchStatus(5)).toBe("COMPLETED"); // Grand final (round 1) - expect(bracket.matchStatus(6)).toBe("STARTED"); // Grand final (round 2) + expect(Engine.matchStatus(data, 5)).toBe("COMPLETED"); // Grand final (round 1) + expect(Engine.matchStatus(data, 6)).toBe("STARTED"); // Grand final (round 2) - bracket.updateMatch({ - id: 6, // Grand Final round 2 - opponent1: { score: 16 }, - opponent2: { score: 10 }, - winnerSide: "opponent1", - }); + expect(() => + Engine.reportResult(data, { + matchId: 6, // Grand Final round 2 + opponent1: { score: 16 }, + opponent2: { score: 10 }, + winnerSide: "opponent1", + }), + ).not.toThrow(); }); test("should determine next matches and reset them", () => { - bracket.create({ + let data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4], settings: {}, }); - bracket.updateMatch({ - id: 0, // First match of WB round 1 + data = Engine.reportResult(data, { + matchId: 0, // First match of WB round 1 opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - const beforeReset = bracket.match(3); // Determined opponent for LB round 1 - expect(beforeReset.opponent1?.id).toBe(bracket.match(0).opponent2?.id); + const beforeReset = matchById(data, 3); // Determined opponent for LB round 1 + expect(beforeReset.opponent1?.id).toBe(matchById(data, 0).opponent2?.id); expect(beforeReset.opponent1?.position).toBe(1); // Must be set. - bracket.resetMatchResults(0); // First match of WB round 1 + data = Engine.resetMatchResults(data, 0).data; // First match of WB round 1 - const afterReset = bracket.match(3); // Determined opponent for LB round 1 + const afterReset = matchById(data, 3); // Determined opponent for LB round 1 expect(afterReset.opponent1?.id).toBeNull(); expect(afterReset.opponent1?.position).toBe(1); // It must stay. }); test("should choose the correct previous and next matches based on losers ordering", () => { - bracket.create({ + let data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], settings: {}, }); - bracket.updateMatch({ id: 0, winnerSide: "opponent1" }); // WB 1.1 + data = Engine.reportResult(data, { + matchId: 0, + winnerSide: "opponent1", + }).data; // WB 1.1 expect( - bracket.match(15).opponent1?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers) - ).toBe(bracket.match(0).opponent2?.id); // Loser of first match round 1 + matchById(data, 15).opponent1?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers) + ).toBe(matchById(data, 0).opponent2?.id); // Loser of first match round 1 - bracket.updateMatch({ id: 1, winnerSide: "opponent1" }); // WB 1.2 + data = Engine.reportResult(data, { + matchId: 1, + winnerSide: "opponent1", + }).data; // WB 1.2 expect( - bracket.match(15).opponent2?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers) - ).toBe(bracket.match(1).opponent2?.id); // Loser of second match round 1 + matchById(data, 15).opponent2?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers) + ).toBe(matchById(data, 1).opponent2?.id); // Loser of second match round 1 - bracket.updateMatch({ id: 8, winnerSide: "opponent1" }); // WB 2.1 + data = Engine.reportResult(data, { + matchId: 8, + winnerSide: "opponent1", + }).data; // WB 2.1 expect( - bracket.match(20).opponent1?.id, // Determined opponent for first match of LB round 2 - ).toBe(bracket.match(8).opponent2?.id); // Loser of first match round 2 + matchById(data, 20).opponent1?.id, // Determined opponent for first match of LB round 2 + ).toBe(matchById(data, 8).opponent2?.id); // Loser of first match round 2 - bracket.updateMatch({ id: 6, winnerSide: "opponent1" }); // WB 1.7 - bracket.updateMatch({ id: 7, winnerSide: "opponent1" }); // WB 1.8 - bracket.updateMatch({ id: 11, winnerSide: "opponent1" }); // WB 2.4 - bracket.updateMatch({ id: 15, winnerSide: "opponent1" }); // LB 1.1 - bracket.updateMatch({ id: 18, winnerSide: "opponent1" }); // LB 1.4 + for (const matchId of [ + 6, // WB 1.7 + 7, // WB 1.8 + 11, // WB 2.4 + 15, // LB 1.1 + 18, // LB 1.4 + ]) { + data = Engine.reportResult(data, { + matchId, + winnerSide: "opponent1", + }).data; + } - expect(bracket.matchStatus(8)).toBe("COMPLETED"); // WB 2.1 + expect(Engine.matchStatus(data, 8)).toBe("COMPLETED"); // WB 2.1 }); test("should send the losers to the right LB matches in round 1", () => { - bracket.create({ + let data = createResolved({ type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: {}, }); - expect(bracket.match(7).opponent1?.position).toBe(1); - expect(bracket.match(7).opponent2?.position).toBe(2); - expect(bracket.match(8).opponent1?.position).toBe(3); - expect(bracket.match(8).opponent2?.position).toBe(4); + expect(matchById(data, 7).opponent1?.position).toBe(1); + expect(matchById(data, 7).opponent2?.position).toBe(2); + expect(matchById(data, 8).opponent1?.position).toBe(3); + expect(matchById(data, 8).opponent2?.position).toBe(4); // Match of position 1. - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, winnerSide: "opponent1", // Loser id: 8. - }); + }).data; - expect(bracket.match(7).opponent1?.id).toBe(8); + expect(matchById(data, 7).opponent1?.id).toBe(8); // Match of position 2. - bracket.updateMatch({ - id: 1, + data = Engine.reportResult(data, { + matchId: 1, winnerSide: "opponent1", // Loser id: 5. - }); + }).data; - expect(bracket.match(7).opponent2?.id).toBe(5); + expect(matchById(data, 7).opponent2?.id).toBe(5); // Match of position 3. - bracket.updateMatch({ - id: 2, + data = Engine.reportResult(data, { + matchId: 2, winnerSide: "opponent1", // Loser id: 7. - }); + }).data; - expect(bracket.match(8).opponent1?.id).toBe(7); + expect(matchById(data, 8).opponent1?.id).toBe(7); // Match of position 4. - bracket.updateMatch({ - id: 3, + data = Engine.reportResult(data, { + matchId: 3, winnerSide: "opponent1", // Loser id: 6. - }); + }).data; - expect(bracket.match(8).opponent2?.id).toBe(6); + expect(matchById(data, 8).opponent2?.id).toBe(6); }); }); + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/propagation/round-robin.test.ts b/app/features/tournament-bracket/core/engine/propagation/round-robin.test.ts index 38dad9524..ae48f32b8 100644 --- a/app/features/tournament-bracket/core/engine/propagation/round-robin.test.ts +++ b/app/features/tournament-bracket/core/engine/propagation/round-robin.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, test } from "vitest"; -import { EngineBracket } from "../test-utils"; - -const bracket = new EngineBracket(); +import { createResolved } from "../create"; +import * as Engine from "../index"; +import type { BracketData } from "../types"; describe("Update scores in a round-robin stage", () => { + let data: BracketData; + beforeEach(() => { - bracket.reset(); - bracket.create({ + data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4], settings: { groupCount: 1 }, @@ -14,47 +15,54 @@ describe("Update scores in a round-robin stage", () => { }); test("should set all the scores", () => { - bracket.updateMatch({ - id: 0, - opponent1: { score: 16 }, - opponent2: { score: 9 }, // AQUELLEHEURE?! - winnerSide: "opponent1", // POCEBLO - }); + const results = [ + { + matchId: 0, + opponent1: { score: 16 }, + opponent2: { score: 9 }, // AQUELLEHEURE?! + winnerSide: "opponent1" as const, // POCEBLO + }, + { + matchId: 1, + opponent1: { score: 3 }, // Ballec Squad + opponent2: { score: 16 }, + winnerSide: "opponent2" as const, // twitch.tv/mrs_fly + }, + { + matchId: 2, + opponent1: { score: 16 }, + opponent2: { score: 0 }, // AQUELLEHEURE?! + winnerSide: "opponent1" as const, // twitch.tv/mrs_fly + }, + { + matchId: 3, + opponent1: { score: 16 }, + opponent2: { score: 2 }, // Ballec Squad + winnerSide: "opponent1" as const, // POCEBLO + }, + { + matchId: 4, + opponent1: { score: 16 }, + opponent2: { score: 12 }, // AQUELLEHEURE?! + winnerSide: "opponent1" as const, // Ballec Squad + }, + { + matchId: 5, + opponent1: { score: 4 }, // twitch.tv/mrs_fly + opponent2: { score: 16 }, + winnerSide: "opponent2" as const, // POCEBLO + }, + ]; - bracket.updateMatch({ - id: 1, - opponent1: { score: 3 }, // Ballec Squad - opponent2: { score: 16 }, - winnerSide: "opponent2", // twitch.tv/mrs_fly - }); + for (const result of results) { + data = Engine.reportResult(data, result).data; + } - bracket.updateMatch({ - id: 2, - opponent1: { score: 16 }, - opponent2: { score: 0 }, // AQUELLEHEURE?! - winnerSide: "opponent1", // twitch.tv/mrs_fly - }); - - bracket.updateMatch({ - id: 3, - opponent1: { score: 16 }, - opponent2: { score: 2 }, // Ballec Squad - winnerSide: "opponent1", // POCEBLO - }); - - bracket.updateMatch({ - id: 4, - opponent1: { score: 16 }, - opponent2: { score: 12 }, // AQUELLEHEURE?! - winnerSide: "opponent1", // Ballec Squad - }); - - bracket.updateMatch({ - id: 5, - opponent1: { score: 4 }, // twitch.tv/mrs_fly - opponent2: { score: 16 }, - winnerSide: "opponent2", // POCEBLO - }); + for (const result of results) { + expect(matchById(data, result.matchId).winnerSide).toBe( + result.winnerSide, + ); + } }); test("should unlock next round matches as soon as both participants are ready", () => { @@ -64,87 +72,87 @@ describe("Update scores in a round-robin stage", () => { // Round 3: Match 4 (1 vs 4), Match 5 (2 vs 3) // Initially, only round 1 matches should be ready - expect(bracket.matchStatus(0)).toBe("STARTED"); // Ready (1 vs 2) - expect(bracket.matchStatus(1)).toBe("STARTED"); // Ready (3 vs 4) - expect(bracket.matchStatus(2)).toBe("PENDING"); // Locked (1 vs 3) - expect(bracket.matchStatus(3)).toBe("PENDING"); // Locked (2 vs 4) + expect(Engine.matchStatus(data, 0)).toBe("STARTED"); // Ready (1 vs 2) + expect(Engine.matchStatus(data, 1)).toBe("STARTED"); // Ready (3 vs 4) + expect(Engine.matchStatus(data, 2)).toBe("PENDING"); // Locked (1 vs 3) + expect(Engine.matchStatus(data, 3)).toBe("PENDING"); // Locked (2 vs 4) // Complete first match of round 1 (1 vs 2) - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, opponent1: { score: 16 }, opponent2: { score: 9 }, // Team 2 loses winnerSide: "opponent1", // Team 1 wins - }); + }).data; // Round 2 Match 1 (1 vs 3) should still be locked because team 3 hasn't finished // Round 2 Match 2 (2 vs 4) should still be locked because team 4 hasn't finished - expect(bracket.matchStatus(2)).toBe("PENDING"); // Still Locked - expect(bracket.matchStatus(3)).toBe("PENDING"); // Still Locked + expect(Engine.matchStatus(data, 2)).toBe("PENDING"); // Still Locked + expect(Engine.matchStatus(data, 3)).toBe("PENDING"); // Still Locked // Complete second match of round 1 (3 vs 4) - bracket.updateMatch({ - id: 1, + data = Engine.reportResult(data, { + matchId: 1, opponent1: { score: 3 }, // Team 3 loses opponent2: { score: 16 }, winnerSide: "opponent2", // Team 4 wins - }); + }).data; // Now both matches in round 2 should be unlocked // Match 2 (1 vs 3): both team 1 and team 3 have finished round 1 // Match 3 (2 vs 4): both team 2 and team 4 have finished round 1 - expect(bracket.matchStatus(2)).toBe("STARTED"); // Ready - expect(bracket.matchStatus(3)).toBe("STARTED"); // Ready + expect(Engine.matchStatus(data, 2)).toBe("STARTED"); // Ready + expect(Engine.matchStatus(data, 3)).toBe("STARTED"); // Ready }); test("should lock the next round again if a result of the previous round is reset", () => { - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, opponent1: { score: 16 }, opponent2: { score: 9 }, winnerSide: "opponent1", - }); - bracket.updateMatch({ - id: 1, + }).data; + data = Engine.reportResult(data, { + matchId: 1, opponent1: { score: 3 }, opponent2: { score: 16 }, winnerSide: "opponent2", - }); + }).data; - expect(bracket.matchStatus(2)).toBe("STARTED"); + expect(Engine.matchStatus(data, 2)).toBe("STARTED"); - bracket.resetMatchResults(0); + data = Engine.resetMatchResults(data, 0).data; - expect(bracket.matchStatus(2)).toBe("PENDING"); + expect(Engine.matchStatus(data, 2)).toBe("PENDING"); }); test("should keep a started next round match playable if a result of the previous round is reset (issue #2690)", () => { - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, opponent1: { score: 16 }, opponent2: { score: 9 }, winnerSide: "opponent1", - }); - bracket.updateMatch({ - id: 1, + }).data; + data = Engine.reportResult(data, { + matchId: 1, opponent1: { score: 3 }, opponent2: { score: 16 }, winnerSide: "opponent2", - }); + }).data; // team 1 and team 3 start playing their round 2 match - bracket.updateMatch({ - id: 2, + data = Engine.reportResult(data, { + matchId: 2, opponent1: { score: 1 }, opponent2: { score: 0 }, - }); + }).data; - bracket.resetMatchResults(0); + data = Engine.resetMatchResults(data, 0).data; - expect(bracket.matchStatus(2)).toBe("STARTED"); + expect(Engine.matchStatus(data, 2)).toBe("STARTED"); expect(() => - bracket.updateMatch({ - id: 2, + Engine.reportResult(data, { + matchId: 2, opponent1: { score: 2 }, opponent2: { score: 0 }, winnerSide: "opponent1", @@ -153,22 +161,20 @@ describe("Update scores in a round-robin stage", () => { }); test("should leave every match Ready when independentRounds is set", () => { - bracket.reset(); - bracket.create({ + data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4], settings: { groupCount: 1, independentRounds: true }, }); - for (const match of bracket.matches()) { - expect(bracket.matchStatus(match.id)).toBe("STARTED"); + for (const match of data.match) { + expect(Engine.matchStatus(data, match.id)).toBe("STARTED"); } // reporting a round-2 match before round-1 finishes must not throw - const round2Match = bracket.match(2); expect(() => - bracket.updateMatch({ - id: round2Match.id, + Engine.reportResult(data, { + matchId: 2, opponent1: { score: 16 }, opponent2: { score: 4 }, winnerSide: "opponent1", @@ -177,25 +183,24 @@ describe("Update scores in a round-robin stage", () => { }); test("should let the only real match be played in a group with fewer teams than slots", () => { - bracket.reset(); // Group sized for 3 but only 2 teams placed (the 3rd slot is a BYE). // The two real teams only meet in round 3, preceded by two BYE rounds // that can never be reported. The real match must still be playable. - bracket.create({ + data = createResolved({ type: "round_robin", seeding: [1, 2, null], settings: { groupCount: 1 }, }); - const realMatch = bracket - .matches() - .find((m) => m.opponent1?.id && m.opponent2?.id)!; + const realMatch = data.match.find( + (match) => match.opponent1?.id && match.opponent2?.id, + )!; - expect(bracket.matchStatus(realMatch.id)).toBe("STARTED"); + expect(Engine.matchStatus(data, realMatch.id)).toBe("STARTED"); expect(() => - bracket.updateMatch({ - id: realMatch.id, + Engine.reportResult(data, { + matchId: realMatch.id, opponent1: { score: 16 }, opponent2: { score: 9 }, winnerSide: "opponent1", @@ -204,9 +209,8 @@ describe("Update scores in a round-robin stage", () => { }); test("should unlock next round matches with BYE participants", () => { - bracket.reset(); // Create a round robin with 3 teams (odd number creates rounds where one team doesn't play) - bracket.create({ + data = createResolved({ type: "round_robin", seeding: [1, 2, 3], settings: { groupCount: 1 }, @@ -217,32 +221,42 @@ describe("Update scores in a round-robin stage", () => { // Round 2: Match (teams 1 vs 3) - Team 2 doesn't play // Round 3: Match (teams 2 vs 1) - Team 3 doesn't play - const allMatches = bracket.matches(); - const allRounds = bracket.rounds(); - // Find the actual match (not BYE vs BYE which doesn't exist) - const round1RealMatch = allMatches.find( - (m) => m.roundId === allRounds[0].id && m.opponent1 && m.opponent2, + const round1RealMatch = data.match.find( + (match) => + match.roundId === data.round[0].id && + match.opponent1 && + match.opponent2, )!; - const round2RealMatch = allMatches.find( - (m) => m.roundId === allRounds[1].id && m.opponent1 && m.opponent2, + const round2RealMatch = data.match.find( + (match) => + match.roundId === data.round[1].id && + match.opponent1 && + match.opponent2, )!; - expect(bracket.matchStatus(round1RealMatch.id)).toBe("STARTED"); - expect(bracket.matchStatus(round2RealMatch.id)).toBe("PENDING"); // initially + expect(Engine.matchStatus(data, round1RealMatch.id)).toBe("STARTED"); + expect(Engine.matchStatus(data, round2RealMatch.id)).toBe("PENDING"); // initially // Complete the only real match in round 1 (teams 3 vs 2) // Team 1 didn't play in round 1 - bracket.updateMatch({ - id: round1RealMatch.id, + data = Engine.reportResult(data, { + matchId: round1RealMatch.id, opponent1: { score: 16 }, opponent2: { score: 9 }, winnerSide: "opponent1", - }); + }).data; // The real match in round 2 (teams 1 vs 3) should now be unlocked // because team 1 didn't play in round 1 (considered ready) // and team 3 just finished their match - expect(bracket.matchStatus(round2RealMatch.id)).toBe("STARTED"); + expect(Engine.matchStatus(data, round2RealMatch.id)).toBe("STARTED"); }); }); + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/propagation/single-elimination.test.ts b/app/features/tournament-bracket/core/engine/propagation/single-elimination.test.ts index 0d2533fb6..5810f9ca1 100644 --- a/app/features/tournament-bracket/core/engine/propagation/single-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/propagation/single-elimination.test.ts @@ -1,93 +1,102 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { EngineBracket } from "../test-utils"; - -const bracket = new EngineBracket(); +import { describe, expect, test } from "vitest"; +import { createResolved } from "../create"; +import * as Engine from "../index"; +import type { BracketData } from "../types"; describe("Previous and next match update", () => { - beforeEach(() => { - bracket.reset(); - }); - test("should determine matches in consolation final", () => { - bracket.create({ + let data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4], settings: { consolationFinal: true }, }); - bracket.updateMatch({ - id: 0, // First match of round 1 + data = Engine.reportResult(data, { + matchId: 0, // First match of round 1 opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - bracket.updateMatch({ - id: 1, // Second match of round 1 + data = Engine.reportResult(data, { + matchId: 1, // Second match of round 1 opponent1: { score: 13 }, opponent2: { score: 16 }, winnerSide: "opponent2", - }); + }).data; - expect(bracket.match(3).opponent1?.id).toBe(bracket.match(0).opponent2?.id); - expect(bracket.match(3).opponent2?.id).toBe(bracket.match(1).opponent1?.id); - expect(bracket.matchStatus(2)).toBe("STARTED"); - expect(bracket.matchStatus(3)).toBe("STARTED"); + expect(matchById(data, 3).opponent1?.id).toBe( + matchById(data, 0).opponent2?.id, + ); + expect(matchById(data, 3).opponent2?.id).toBe( + matchById(data, 1).opponent1?.id, + ); + expect(Engine.matchStatus(data, 2)).toBe("STARTED"); + expect(Engine.matchStatus(data, 3)).toBe("STARTED"); }); test("should play both the final and consolation final in parallel", () => { - bracket.create({ + let data = createResolved({ type: "single_elimination", seeding: [1, 2, 3, 4], settings: { consolationFinal: true }, }); - bracket.updateMatch({ - id: 0, // First match of round 1 + data = Engine.reportResult(data, { + matchId: 0, // First match of round 1 opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - bracket.updateMatch({ - id: 1, // Second match of round 1 + data = Engine.reportResult(data, { + matchId: 1, // Second match of round 1 opponent1: { score: 13 }, opponent2: { score: 16 }, winnerSide: "opponent2", - }); + }).data; - bracket.updateMatch({ - id: 2, // Final + data = Engine.reportResult(data, { + matchId: 2, // Final opponent1: { score: 12 }, opponent2: { score: 9 }, - }); + }).data; - expect(bracket.matchStatus(2)).toBe("STARTED"); - expect(bracket.matchStatus(3)).toBe("STARTED"); + expect(Engine.matchStatus(data, 2)).toBe("STARTED"); + expect(Engine.matchStatus(data, 3)).toBe("STARTED"); - bracket.updateMatch({ - id: 3, // Consolation final + data = Engine.reportResult(data, { + matchId: 3, // Consolation final opponent1: { score: 12 }, opponent2: { score: 9 }, - }); + }).data; - expect(bracket.matchStatus(2)).toBe("STARTED"); - expect(bracket.matchStatus(3)).toBe("STARTED"); + expect(Engine.matchStatus(data, 2)).toBe("STARTED"); + expect(Engine.matchStatus(data, 3)).toBe("STARTED"); - bracket.updateMatch({ - id: 3, // Consolation final + data = Engine.reportResult(data, { + matchId: 3, // Consolation final opponent1: { score: 16 }, opponent2: { score: 9 }, winnerSide: "opponent1", - }); + }).data; - expect(bracket.matchStatus(2)).toBe("STARTED"); + expect(Engine.matchStatus(data, 2)).toBe("STARTED"); - bracket.updateMatch({ - id: 2, // Final - opponent1: { score: 16 }, - opponent2: { score: 9 }, - winnerSide: "opponent1", - }); + expect(() => + Engine.reportResult(data, { + matchId: 2, // Final + opponent1: { score: 16 }, + opponent2: { score: 9 }, + winnerSide: "opponent1", + }), + ).not.toThrow(); }); }); + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/propagation/update.test.ts b/app/features/tournament-bracket/core/engine/propagation/update.test.ts index 7fdb43e02..06bdcd6ab 100644 --- a/app/features/tournament-bracket/core/engine/propagation/update.test.ts +++ b/app/features/tournament-bracket/core/engine/propagation/update.test.ts @@ -1,43 +1,43 @@ import { beforeEach, describe, expect, test } from "vitest"; -import { EngineBracket } from "../test-utils"; +import { createResolved } from "../create"; +import * as Engine from "../index"; +import type { BracketData, ResolvedCreateBracketInput } from "../types"; -const bracket = new EngineBracket(); - -const example = { - type: "double_elimination" as const, +const EXAMPLE: ResolvedCreateBracketInput = { + type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], settings: {}, }; describe("Update matches", () => { + let data: BracketData; + beforeEach(() => { - bracket.reset(); - bracket.create(example); + data = createResolved(EXAMPLE); }); test("should start a match", () => { - expect(bracket.matchStatus(0)).toBe("STARTED"); + expect(Engine.matchStatus(data, 0)).toBe("STARTED"); - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, opponent1: { score: 0 }, opponent2: { score: 0 }, - }); + }).data; - const after = bracket.match(0); - expect(bracket.matchStatus(0)).toBe("STARTED"); - expect(after.opponent1?.score).toBe(0); + expect(Engine.matchStatus(data, 0)).toBe("STARTED"); + expect(matchById(data, 0).opponent1?.score).toBe(0); }); test("should update the scores for a match", () => { - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, opponent1: { score: 2 }, opponent2: { score: 1 }, - }); + }).data; - const after = bracket.match(0); - expect(bracket.matchStatus(0)).toBe("STARTED"); + const after = matchById(data, 0); + expect(Engine.matchStatus(data, 0)).toBe("STARTED"); expect(after.opponent1?.score).toBe(2); // Id should stay. It shouldn't be overwritten. @@ -45,109 +45,103 @@ describe("Update matches", () => { }); test("should end the match by only setting the winner", () => { - const before = bracket.match(0); - expect(before.winnerSide).toBeFalsy(); + expect(matchById(data, 0).winnerSide).toBeFalsy(); - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, winnerSide: "opponent1", - }); + }).data; - const after = bracket.match(0); - expect(bracket.matchStatus(0)).toBe("COMPLETED"); - expect(after.winnerSide).toBe("opponent1"); + expect(Engine.matchStatus(data, 0)).toBe("COMPLETED"); + expect(matchById(data, 0).winnerSide).toBe("opponent1"); }); test("should change the winner of the match and update in the next match", () => { - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, winnerSide: "opponent1", - }); + }).data; - expect(bracket.match(8).opponent1?.id).toBe(1); + expect(matchById(data, 8).opponent1?.id).toBe(1); - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, winnerSide: "opponent2", - }); + }).data; - const after = bracket.match(0); - expect(bracket.matchStatus(0)).toBe("COMPLETED"); - expect(after.winnerSide).toBe("opponent2"); + expect(Engine.matchStatus(data, 0)).toBe("COMPLETED"); + expect(matchById(data, 0).winnerSide).toBe("opponent2"); - const nextMatch = bracket.match(8); - expect(bracket.matchStatus(8)).toBe("PENDING"); - expect(nextMatch.opponent1?.id).toBe(16); + expect(Engine.matchStatus(data, 8)).toBe("PENDING"); + expect(matchById(data, 8).opponent1?.id).toBe(16); }); test("should update the status of the next match", () => { - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, winnerSide: "opponent1", - }); + }).data; - expect(bracket.matchStatus(8)).toBe("PENDING"); + expect(Engine.matchStatus(data, 8)).toBe("PENDING"); - bracket.updateMatch({ - id: 1, + data = Engine.reportResult(data, { + matchId: 1, winnerSide: "opponent1", - }); + }).data; - expect(bracket.matchStatus(8)).toBe("STARTED"); + expect(Engine.matchStatus(data, 8)).toBe("STARTED"); }); test("should remove results from a match without score", () => { - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, winnerSide: "opponent1", - }); + }).data; - bracket.resetMatchResults(0); + data = Engine.resetMatchResults(data, 0).data; - const after = bracket.match(0); - expect(bracket.matchStatus(0)).toBe("STARTED"); - expect(after.winnerSide).toBeFalsy(); + expect(Engine.matchStatus(data, 0)).toBe("STARTED"); + expect(matchById(data, 0).winnerSide).toBeFalsy(); }); test("should remove results from a match with score", () => { - bracket.updateMatch({ - id: 0, + data = Engine.reportResult(data, { + matchId: 0, opponent1: { score: 16 }, opponent2: { score: 12 }, winnerSide: "opponent1", - }); + }).data; - bracket.resetMatchResults(0); + data = Engine.resetMatchResults(data, 0).data; - const after = bracket.match(0); - expect(bracket.matchStatus(0)).toBe("STARTED"); - expect(after.winnerSide).toBeFalsy(); + expect(Engine.matchStatus(data, 0)).toBe("STARTED"); + expect(matchById(data, 0).winnerSide).toBeFalsy(); }); test("should not set the other score to 0 if only one given", () => { // It shouldn't be our decision to set the other score to 0. - bracket.updateMatch({ - id: 1, + data = Engine.reportResult(data, { + matchId: 1, opponent1: { score: 1 }, - }); + }).data; - const after = bracket.match(1); - expect(bracket.matchStatus(1)).toBe("STARTED"); + const after = matchById(data, 1); + expect(Engine.matchStatus(data, 1)).toBe("STARTED"); expect(after.opponent1?.score).toBe(1); expect(after.opponent2?.score).toBeFalsy(); }); test("should end the match by setting the winner and the scores", () => { - bracket.updateMatch({ - id: 1, + data = Engine.reportResult(data, { + matchId: 1, opponent1: { score: 6 }, opponent2: { score: 3 }, winnerSide: "opponent2", - }); + }).data; - const after = bracket.match(1); - expect(bracket.matchStatus(1)).toBe("COMPLETED"); + const after = matchById(data, 1); + expect(Engine.matchStatus(data, 1)).toBe("COMPLETED"); expect(after.winnerSide).toBe("opponent2"); expect(after.opponent1?.score).toBe(6); @@ -156,24 +150,33 @@ describe("Update matches", () => { }); describe("Locked matches", () => { + let data: BracketData; + beforeEach(() => { - bracket.reset(); - bracket.create(example); + data = createResolved(EXAMPLE); }); test("should throw when the matches leading to the match have not been completed yet", () => { - bracket.updateMatch({ id: 0 }); // No problem when no previous match. - expect(() => bracket.updateMatch({ id: 8 })).toThrow( + expect(() => Engine.reportResult(data, { matchId: 0 })).not.toThrow(); // No problem when no previous match. + + expect(() => Engine.reportResult(data, { matchId: 8 })).toThrow( "The match is locked.", ); // First match of WB Round 2. - expect(() => bracket.updateMatch({ id: 15 })).toThrow( + expect(() => Engine.reportResult(data, { matchId: 15 })).toThrow( "The match is locked.", ); // First match of LB Round 1. - expect(() => bracket.updateMatch({ id: 19 })).toThrow( + expect(() => Engine.reportResult(data, { matchId: 19 })).toThrow( "The match is locked.", ); // First match of LB Round 1. - expect(() => bracket.updateMatch({ id: 23 })).toThrow( + expect(() => Engine.reportResult(data, { matchId: 23 })).toThrow( "The match is locked.", ); // First match of LB Round 3. }); }); + +function matchById(data: BracketData, id: number) { + const found = data.match.find((match) => match.id === id); + if (!found) throw new Error(`Match ${id} not found`); + + return found; +} diff --git a/app/features/tournament-bracket/core/engine/test-utils.ts b/app/features/tournament-bracket/core/engine/test-utils.ts deleted file mode 100644 index e9660c86a..000000000 --- a/app/features/tournament-bracket/core/engine/test-utils.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Test-only harness giving the pure engine a stateful surface similar to the -// old BracketsManager, so the vendored test suite could be ported 1:1. - -import { createResolved } from "./create"; -import * as Engine from "./index"; -import type { MatchStatus } from "./status"; -import type { - BracketData, - GroupData, - MatchData, - MatchResultsInput, - ResolvedCreateBracketInput, - RoundData, - StageData, -} from "./types"; - -interface UpdateMatchInput extends MatchResultsInput { - id: number; -} - -export class EngineBracket { - data: BracketData | undefined; - - create( - input: Omit & - Partial>, - ): void { - const created = createResolved({ - ...input, - settings: input.settings ?? {}, - }); - - if (!this.data) { - this.data = created; - return; - } - - // stack another stage into the same data set, offsetting the local ids - // the same way the old library's storage assigned continuing ids - const offsets = { - stage: this.data.stage.length, - group: this.data.group.length, - round: this.data.round.length, - match: this.data.match.length, - }; - - this.data = { - stage: [ - ...this.data.stage, - ...created.stage.map((stage) => ({ - ...stage, - id: stage.id + offsets.stage, - number: input.number ?? offsets.stage + 1, - })), - ], - group: [ - ...this.data.group, - ...created.group.map((group) => ({ - ...group, - id: group.id + offsets.group, - stageId: group.stageId + offsets.stage, - })), - ], - round: [ - ...this.data.round, - ...created.round.map((round) => ({ - ...round, - id: round.id + offsets.round, - stageId: round.stageId + offsets.stage, - groupId: round.groupId + offsets.group, - })), - ], - match: [ - ...this.data.match, - ...created.match.map((match) => ({ - ...match, - id: match.id + offsets.match, - stageId: match.stageId + offsets.stage, - groupId: match.groupId + offsets.group, - roundId: match.roundId + offsets.round, - })), - ], - }; - } - - reset(): void { - this.data = undefined; - } - - updateMatch(input: UpdateMatchInput, force?: boolean): void { - this.data = Engine.reportResult(this.currentData(), { - matchId: input.id, - opponent1: input.opponent1, - opponent2: input.opponent2, - winnerSide: input.winnerSide, - force, - }).data; - } - - resetMatchResults(matchId: number): void { - this.data = Engine.resetMatchResults(this.currentData(), matchId).data; - } - - stage(id = 0): StageData { - const stage = this.currentData().stage.find((s) => s.id === id); - if (!stage) throw Error(`Stage ${id} not found`); - return stage; - } - - groups(): GroupData[] { - return this.currentData().group; - } - - rounds(filter?: Partial): RoundData[] { - return applyFilter(this.currentData().round, filter); - } - - round(id: number): RoundData { - const round = this.currentData().round.find((r) => r.id === id); - if (!round) throw Error(`Round ${id} not found`); - return round; - } - - matches(filter?: Partial): MatchData[] { - return applyFilter(this.currentData().match, filter); - } - - match(id: number): MatchData { - const match = this.currentData().match.find((m) => m.id === id); - if (!match) throw Error(`Match ${id} not found`); - return match; - } - - matchStatus(id: number): MatchStatus { - return Engine.matchStatus(this.currentData(), id); - } - - private currentData(): BracketData { - if (!this.data) throw Error("No bracket created"); - return this.data; - } -} - -function applyFilter(rows: T[], filter?: Partial): T[] { - if (!filter) return rows; - - const entries = Object.entries(filter); - return rows.filter((row) => - entries.every(([key, value]) => row[key as keyof T] === value), - ); -} diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index f70811b1e..3244d77b1 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -119,6 +119,58 @@ export const testTournament = ({ }); }; +/** + * Combines separately created brackets into the bracket data of one tournament, + * offsetting the local ids of every bracket after the first the same way the + * database does when a new stage is added to an existing tournament. + */ +export const mergeStages = (...brackets: BracketData[]): BracketData => { + const merged: BracketData = { stage: [], group: [], round: [], match: [] }; + + for (const bracket of brackets) { + const offsets = { + stage: merged.stage.length, + group: merged.group.length, + round: merged.round.length, + match: merged.match.length, + }; + + merged.stage.push( + ...bracket.stage.map((stage) => ({ + ...stage, + id: stage.id + offsets.stage, + number: offsets.stage + 1, + })), + ); + merged.group.push( + ...bracket.group.map((group) => ({ + ...group, + id: group.id + offsets.group, + stageId: group.stageId + offsets.stage, + })), + ); + merged.round.push( + ...bracket.round.map((round) => ({ + ...round, + id: round.id + offsets.round, + stageId: round.stageId + offsets.stage, + groupId: round.groupId + offsets.group, + })), + ); + merged.match.push( + ...bracket.match.map((match) => ({ + ...match, + id: match.id + offsets.match, + stageId: match.stageId + offsets.stage, + groupId: match.groupId + offsets.group, + roundId: match.roundId + offsets.round, + })), + ); + } + + return merged; +}; + const DEFAULT_PROGRESSION_ARGS = { requiresCheckIn: false, settings: {}, diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index 595366f01..b27000add 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { EngineBracket } from "~/features/tournament-bracket/core/engine/test-utils"; +import * as Engine from "~/features/tournament-bracket/core/engine"; +import { createResolved } from "~/features/tournament-bracket/core/engine/create"; +import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; import { + mergeStages, progressions, testTournament, tournamentCtxTeam, @@ -157,39 +160,20 @@ describe("matchesPlayed", () => { }); function roundRobinToSingleEliminationTournament() { - const bracket = new EngineBracket(); - - bracket.create({ - type: "round_robin", - seeding: [1, 2, 3, 4], - settings: { groupCount: 1 }, - }); - bracket.create({ - type: "single_elimination", - seeding: [1, 2], - settings: {}, - }); - - // play every match across both brackets, lower id always wins - while (true) { - const pending = bracket - .matches() - .find( - (m) => - typeof m.opponent1?.id === "number" && - typeof m.opponent2?.id === "number" && - !m.winnerSide, - ); - if (!pending) break; - - const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!; - bracket.updateMatch({ - id: pending.id, - opponent1: { score: winnerIsOpp1 ? 2 : 0 }, - opponent2: { score: winnerIsOpp1 ? 0 : 2 }, - winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", - }); - } + const data = playOutLowerIdWins( + mergeStages( + createResolved({ + type: "round_robin", + seeding: [1, 2, 3, 4], + settings: { groupCount: 1 }, + }), + createResolved({ + type: "single_elimination", + seeding: [1, 2], + settings: {}, + }), + ), + ); return testTournament({ ctx: { @@ -203,38 +187,18 @@ function roundRobinToSingleEliminationTournament() { tournamentCtxTeam(4, { startingBracketIdx: 0, seed: 4 }), ], }, - data: bracket.data!, + data, }); } function singleEliminationTournament() { - const bracket = new EngineBracket(); - - bracket.create({ - type: "single_elimination", - seeding: [1, 2, 3, 4], - settings: {}, - }); - - while (true) { - const pending = bracket - .matches() - .find( - (m) => - typeof m.opponent1?.id === "number" && - typeof m.opponent2?.id === "number" && - !m.winnerSide, - ); - if (!pending) break; - - const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!; - bracket.updateMatch({ - id: pending.id, - opponent1: { score: winnerIsOpp1 ? 2 : 0 }, - opponent2: { score: winnerIsOpp1 ? 0 : 2 }, - winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", - }); - } + const data = playOutLowerIdWins( + createResolved({ + type: "single_elimination", + seeding: [1, 2, 3, 4], + settings: {}, + }), + ); return testTournament({ ctx: { @@ -248,14 +212,12 @@ function singleEliminationTournament() { tournamentCtxTeam(4, { seed: 4 }), ], }, - data: bracket.data!, + data, }); } function abDivisionsTournament() { - const bracket = new EngineBracket(); - - bracket.create({ + let data = createResolved({ type: "round_robin", seeding: [1, 2, 3, 4], abDivisions: [0, 1, 0, 1], @@ -271,7 +233,7 @@ function abDivisionsTournament() { "2-3": 2, "3-4": 3, }; - for (const match of bracket.matches()) { + for (const match of data.match) { const a = match.opponent1!.id as number; const b = match.opponent2!.id as number; const key = a < b ? `${a}-${b}` : `${b}-${a}`; @@ -279,16 +241,14 @@ function abDivisionsTournament() { invariant(winnerId, `unexpected matchup ${key}`); const loserScore = key === "2-3" || key === "3-4" ? 1 : 0; const winnerIsOpp1 = match.opponent1!.id === winnerId; - bracket.updateMatch({ - id: match.id, + data = Engine.reportResult(data, { + matchId: match.id, opponent1: { score: winnerIsOpp1 ? 2 : loserScore }, opponent2: { score: winnerIsOpp1 ? loserScore : 2 }, winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", - }); + }).data; } - const data = bracket.data!; - return testTournament({ ctx: { settings: { @@ -311,3 +271,28 @@ function abDivisionsTournament() { data, }); } + +/** Plays every match of the bracket data, the lower team id always winning. */ +function playOutLowerIdWins(data: BracketData) { + let played = data; + + while (true) { + const pending = played.match.find( + (match) => + typeof match.opponent1?.id === "number" && + typeof match.opponent2?.id === "number" && + !match.winnerSide, + ); + if (!pending) break; + + const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!; + played = Engine.reportResult(played, { + matchId: pending.id, + opponent1: { score: winnerIsOpp1 ? 2 : 0 }, + opponent2: { score: winnerIsOpp1 ? 0 : 2 }, + winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", + }).data; + } + + return played; +}