sendou.ink/app/features/tournament-bracket/core/Swiss.test.ts
2025-12-27 10:28:31 +02:00

525 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
LOW_INK_AUGUST_2025,
RUSH_WEEKEND_3,
} from "~/features/tournament-bracket/core/tests/mocks-swiss";
import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly";
import invariant from "~/utils/invariant";
import * as Swiss from "./Swiss";
describe("Swiss", () => {
const createArgsWithDefaults = (
args: Partial<Parameters<typeof Swiss.create>[0]> = {},
): Parameters<typeof Swiss.create>[0] => {
return {
name: "Swiss Tournament",
seeding: [1, 2, 3, 4],
settings: {},
tournamentId: 1,
...args,
};
};
describe("create()", () => {
it("attaches the correct tournament id to the data", () => {
const data = Swiss.create(createArgsWithDefaults());
expect(data.stage[0].tournament_id).toBe(1);
});
it("creates a swiss bracket with correct amount of initial matches", () => {
const data = Swiss.create(createArgsWithDefaults());
expect(data.match).toHaveLength(2);
});
it("creates a swiss bracket with correct amount of rounds as default", () => {
const data = Swiss.create(createArgsWithDefaults());
expect(data.round).toHaveLength(5);
});
it("creates a swiss bracket with correct amount of rounds as parameter", () => {
const data = Swiss.create(
createArgsWithDefaults({
settings: {
swiss: {
groupCount: 1,
roundCount: 4,
},
},
}),
);
expect(data.round).toHaveLength(4);
});
it("creates a swiss bracket with two groups", () => {
const data = Swiss.create(
createArgsWithDefaults({
settings: {
swiss: {
groupCount: 2,
roundCount: 5,
},
},
}),
);
expect(data.round).toHaveLength(10);
const matchGroupIds = data.match.map((m) => m.group_id);
expect(matchGroupIds).toContain(0);
expect(matchGroupIds).toContain(1);
});
it("every team has a match", () => {
const data = Swiss.create(createArgsWithDefaults());
for (const teamId of [1, 2, 3, 4]) {
expect(
data.match.some(
(match) =>
match.opponent1?.id === teamId || match.opponent2?.id === teamId,
),
).toBe(true);
}
});
it("assigns a BYE if odd number of teams", () => {
const data = Swiss.create(
createArgsWithDefaults({
seeding: [1, 2, 3, 4, 5],
}),
);
const byes = data.match.filter((match) => match.opponent2 === null);
expect(byes).toHaveLength(1);
});
it("if no teams, should generate a bracket data with no matches", () => {
const data = Swiss.create(createArgsWithDefaults({ seeding: [] }));
expect(data.match).toHaveLength(0);
});
});
describe("generateMatchUps()", () => {
describe("Zones Weekly 38", () => {
const tournament = new Tournament({
...ZONES_WEEKLY_38(),
simulateBrackets: false,
});
const bracket = tournament.bracketByIdx(0)!;
const matches = Swiss.generateMatchUps({
bracket,
groupId: 4443,
})._unsafeUnwrap();
it("finds new opponents for each team in the last round", () => {
for (const match of matches) {
if (match.opponentTwo === "null") continue;
const opponent1 = JSON.parse(match.opponentOne).id as number;
const opponent2 = JSON.parse(match.opponentTwo).id as number;
const existingMatch = bracket.data.match.find(
(m) =>
(m.opponent1?.id === opponent1 &&
m.opponent2?.id === opponent2) ||
(m.opponent1?.id === opponent2 && m.opponent2?.id === opponent1),
);
expect(existingMatch).toBeUndefined();
}
});
it("generates a bye", () => {
const byes = matches.filter((match) => match.opponentTwo === "null");
expect(byes).toHaveLength(1);
});
it("every pair is max one set win from each other", () => {
for (const match of matches) {
if (match.opponentTwo === "null") continue;
const opponent1 = JSON.parse(match.opponentOne).id as number;
const opponent2 = JSON.parse(match.opponentTwo).id as number;
const opponent1Stats = bracket.standings.find(
(s) => s.team.id === opponent1,
)?.stats;
const opponent2Stats = bracket.standings.find(
(s) => s.team.id === opponent2,
)?.stats;
invariant(opponent1Stats, "Opponent 1 not found in standings");
invariant(opponent2Stats, "Opponent 2 not found in standings");
expect(
Math.abs(opponent1Stats.setWins - opponent2Stats.setWins),
).toBeLessThanOrEqual(1);
}
});
});
});
const PAIR_UP_TEST_CASES = [RUSH_WEEKEND_3, LOW_INK_AUGUST_2025];
describe("pairUp()", () => {
it.for(
PAIR_UP_TEST_CASES,
)("all teams have matches (pair up test cases idx %#)", (testCase) => {
const result = Swiss.pairUp(testCase);
const inputTeams = testCase.map((team) => team.id).sort((a, b) => a - b);
const resultTeams = result
.flatMap((match) => [match.opponentOne, match.opponentTwo])
.filter((val) => val !== null)
.sort((a, b) => a - b);
expect(inputTeams).toEqual(resultTeams);
});
it.for(
PAIR_UP_TEST_CASES,
)("every pair is max one set win from each other (pair up test cases idx %#)", (testCase) => {
const result = Swiss.pairUp(testCase);
for (const match of result) {
if (match.opponentOne === null || match.opponentTwo === null) continue;
const opponentOneScore = testCase.find(
(t) => t.id === match.opponentOne,
)!.score;
const opponentTwoScore = testCase.find(
(t) => t.id === match.opponentTwo,
)!.score;
expect(
Math.abs(opponentOneScore - opponentTwoScore),
`Teams ${match.opponentOne} and ${match.opponentTwo} have too large score difference (${opponentOneScore} vs ${opponentTwoScore})`,
).toBeLessThanOrEqual(1);
}
});
it.for(
PAIR_UP_TEST_CASES,
)("should match perfect records against each other as much as possible (pair up test cases idx %#)", (testCase) => {
const result = Swiss.pairUp(testCase);
const maxScore = testCase.reduce(
(max, team) => Math.max(max, team.score),
0,
);
const perfectRecordsCount = testCase.filter(
(team) => team.score === maxScore,
).length;
let perfectRecordsPlayingEachOtherCount = 0;
for (const match of result) {
if (match.opponentOne === null || match.opponentTwo === null) continue;
const oneIsPerfectScore = testCase.some(
(team) => team.id === match.opponentOne && team.score === maxScore,
);
const twoIsPerfectScore = testCase.some(
(team) => team.id === match.opponentTwo && team.score === maxScore,
);
if (oneIsPerfectScore && twoIsPerfectScore) {
perfectRecordsPlayingEachOtherCount++;
}
}
expect(perfectRecordsPlayingEachOtherCount).toBe(
Math.floor(perfectRecordsCount / 2),
);
});
it.for(
PAIR_UP_TEST_CASES,
)("generates max one bye (pair up test cases idx %#)", (testCase) => {
const result = Swiss.pairUp(testCase);
let byes = 0;
for (const match of result) {
if (match.opponentOne === null || match.opponentTwo === null) byes++;
}
expect(byes).toBeLessThanOrEqual(1);
});
});
describe("calculateTeamStatus()", () => {
it("returns 'advanced' when team has enough wins", () => {
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 0,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 4,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
});
it("returns 'eliminated' when team has too many losses", () => {
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 1,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
});
it("returns 'active' when team can still advance or be eliminated", () => {
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 2,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
expect(
Swiss.calculateTeamStatus({
wins: 1,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 0,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
});
it("handles different tournament configurations", () => {
// 4-round tournament with advance threshold 2
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 0,
advanceThreshold: 2,
roundCount: 4,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 3,
advanceThreshold: 2,
roundCount: 4,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 1,
losses: 2,
advanceThreshold: 2,
roundCount: 4,
}),
).toBe("active");
// 6-round tournament with advance threshold 4
expect(
Swiss.calculateTeamStatus({
wins: 4,
losses: 1,
advanceThreshold: 4,
roundCount: 6,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 3,
advanceThreshold: 4,
roundCount: 6,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 2,
advanceThreshold: 4,
roundCount: 6,
}),
).toBe("active");
});
it("handles edge cases correctly", () => {
// Team reaches advance threshold exactly
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 2,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
// Team reaches elimination threshold exactly
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
// Tournament where advance threshold equals round count
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 0,
advanceThreshold: 3,
roundCount: 3,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 3,
advanceThreshold: 3,
roundCount: 3,
}),
).toBe("eliminated");
});
});
describe("Threshold validation utilities", () => {
describe("maxAdvanceThreshold()", () => {
it("calculates maximum advance threshold correctly", () => {
expect(Swiss.maxAdvanceThreshold({ roundCount: 3 })).toBe(3); // ceil(3/2) + 1 = 2 + 1 = 3
expect(Swiss.maxAdvanceThreshold({ roundCount: 4 })).toBe(3); // ceil(4/2) + 1 = 2 + 1 = 3
expect(Swiss.maxAdvanceThreshold({ roundCount: 5 })).toBe(4); // ceil(5/2) + 1 = 3 + 1 = 4
expect(Swiss.maxAdvanceThreshold({ roundCount: 6 })).toBe(4); // ceil(6/2) + 1 = 3 + 1 = 4
expect(Swiss.maxAdvanceThreshold({ roundCount: 7 })).toBe(5); // ceil(7/2) + 1 = 4 + 1 = 5
});
});
describe("isValidAdvanceThreshold()", () => {
it("validates correct thresholds", () => {
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 3 }),
).toBe(true);
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 4, advanceThreshold: 2 }),
).toBe(true);
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 6, advanceThreshold: 4 }),
).toBe(true);
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 3, advanceThreshold: 2 }),
).toBe(true);
});
it("rejects invalid thresholds", () => {
// Threshold too high
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 5 }),
).toBe(false); // equals round count
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 6 }),
).toBe(false); // exceeds round count
// Threshold too low
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 0 }),
).toBe(false);
expect(
Swiss.isValidAdvanceThreshold({
roundCount: 3,
advanceThreshold: -1,
}),
).toBe(false);
});
it("handles edge cases", () => {
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 3, advanceThreshold: 2 }),
).toBe(true); // minimum valid
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 4 }),
).toBe(true); // maximum valid for 5 rounds
});
});
describe("validAdvanceThresholdOptions()", () => {
it("returns correct options for different round counts", () => {
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 3 })).toEqual([
2, 3,
]);
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 5 })).toEqual([
2, 3, 4,
]);
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 8 })).toEqual([
2, 3, 4, 5,
]);
});
it("handles minimal round counts", () => {
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 2 })).toEqual([
2,
]);
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 1 })).toEqual([
2,
]);
});
});
});
});