mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 01:09:02 -05:00
525 lines
13 KiB
TypeScript
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,
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
});
|