mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-12 06:06:28 -05:00
Refactor tests
This commit is contained in:
@@ -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<string, number> = {
|
||||
@@ -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<EngineBracket["matches"]>[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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ResolvedCreateBracketInput, "settings"> &
|
||||
Partial<Pick<ResolvedCreateBracketInput, "settings">>,
|
||||
): 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>): 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>): 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<T extends object>(rows: T[], filter?: Partial<T>): T[] {
|
||||
if (!filter) return rows;
|
||||
|
||||
const entries = Object.entries(filter);
|
||||
return rows.filter((row) =>
|
||||
entries.every(([key, value]) => row[key as keyof T] === value),
|
||||
);
|
||||
}
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user