sendou.ink/app/features/tournament-bracket/core/engine/general.test.ts
2026-07-23 18:48:51 +03:00

320 lines
8.7 KiB
TypeScript

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();
describe("BYE handling", () => {
beforeEach(() => {
bracket.reset();
});
test("should propagate BYEs through the brackets", () => {
bracket.create({
name: "Example with BYEs",
tournamentId: 0,
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(bracket.match(3).opponent1).toBe(null);
expect(bracket.match(3).opponent2).toBe(null);
expect(bracket.match(4).opponent1).toBe(null);
expect(bracket.match(4).opponent2).toBe(null);
expect(bracket.match(5).opponent1?.id).toBe(1);
expect(bracket.match(5).opponent2).toBe(null);
});
test("should handle incomplete seeding during creation", () => {
bracket.create({
name: "Example with BYEs",
tournamentId: 0,
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(bracket.match(1).opponent1?.id).toBe(2);
expect(bracket.match(1).opponent2).toBe(null);
});
});
describe("Position checks", () => {
beforeEach(() => {
bracket.reset();
bracket.create({
name: "Example with double grand final",
tournamentId: 0,
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: {},
});
});
test("should not have a position when we don't need the origin of a participant", () => {
const matchFromWbRound2 = bracket.match(4);
expect(matchFromWbRound2.opponent1?.position).toBe(undefined);
expect(matchFromWbRound2.opponent2?.position).toBe(undefined);
const matchFromLbRound2 = bracket.match(9);
expect(matchFromLbRound2.opponent2?.position).toBe(undefined);
const matchFromGrandFinal = bracket.match(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);
expect(matchFromWbRound1.opponent1?.position).toBe(1);
expect(matchFromWbRound1.opponent2?.position).toBe(8);
const matchFromLbRound1 = bracket.match(7);
expect(matchFromLbRound1.opponent1?.position).toBe(1);
expect(matchFromLbRound1.opponent2?.position).toBe(2);
const matchFromLbRound2 = bracket.match(9);
expect(matchFromLbRound2.opponent1?.position).toBe(2);
const matchFromGrandFinal = bracket.match(13);
expect(matchFromGrandFinal.opponent2?.position).toBe(1);
});
});
describe("Special cases", () => {
beforeEach(() => {
bracket.reset();
});
test("should throw if the name of the stage is not provided", () => {
expect(() =>
createResolved({
tournamentId: 0,
type: "single_elimination",
settings: {},
} as Engine.ResolvedCreateBracketInput),
).toThrow("You must provide a name for the stage.");
});
test("should throw if the tournament id of the stage is not provided", () => {
expect(() =>
createResolved({
name: "Example",
type: "single_elimination",
settings: {},
} as Engine.ResolvedCreateBracketInput),
).toThrow("You must provide a tournament id for the stage.");
});
test("should pad the seeding with BYEs to the next power of two", () => {
bracket.create({
name: "Example",
tournamentId: 0,
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);
});
test("should throw if the participant count of a stage is less than two", () => {
expect(() =>
bracket.create({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: [],
}),
).toThrow("Impossible to create a stage with less than 2 participants.");
expect(() =>
bracket.create({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: [1],
}),
).toThrow("Impossible to create a stage with less than 2 participants.");
});
});
describe("Seeding and ordering in elimination", () => {
beforeEach(() => {
bracket.reset();
bracket.create({
name: "Amateur",
tournamentId: 0,
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
settings: {},
});
});
test("should have the good orderings everywhere", () => {
const firstRoundMatchWB = bracket.match(0);
expect(firstRoundMatchWB.opponent1?.position).toBe(1);
expect(firstRoundMatchWB.opponent2?.position).toBe(16);
const firstRoundMatchLB = bracket.match(15);
expect(firstRoundMatchLB.opponent1?.position).toBe(1);
expect(firstRoundMatchLB.opponent2?.position).toBe(2);
const secondRoundMatchLB = bracket.match(19);
expect(secondRoundMatchLB.opponent1?.position).toBe(2);
const secondRoundSecondMatchLB = bracket.match(20);
expect(secondRoundSecondMatchLB.opponent1?.position).toBe(1);
const fourthRoundMatchLB = bracket.match(25);
expect(fourthRoundMatchLB.opponent1?.position).toBe(2);
const finalRoundMatchLB = bracket.match(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({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: [1, null, null, null, null, null, null, 2],
settings: {},
});
bracket.updateMatch({
id: 0,
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
let match = bracket.match(0);
expect(match.opponent1?.score).toBe(16);
expect(match.opponent2?.score).toBe(12);
expect(match.opponent1?.result).toBe("win");
let semi1 = bracket.match(4);
expect(semi1.opponent1?.result).toBe("win");
expect(semi1.opponent2).toBe(null);
let final = bracket.match(6);
expect(final.opponent1?.result).toBe("win");
expect(final.opponent2).toBe(null);
bracket.resetMatchResults(0); // Score stays as is.
match = bracket.match(0);
expect(match.opponent1?.score).toBe(16);
expect(match.opponent2?.score).toBe(12);
expect(match.opponent1?.result).toBe(undefined);
semi1 = bracket.match(4);
expect(semi1.opponent1?.result).toBe(undefined);
expect(semi1.opponent2).toBe(null);
final = bracket.match(6);
expect(final.opponent1?.result).toBe(undefined);
expect(final.opponent2).toBe(null);
});
test("should throw when at least one of the following match is locked", () => {
bracket.create({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
bracket.updateMatch({
id: 0,
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
bracket.updateMatch({
id: 1,
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
bracket.updateMatch({
id: 2,
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
expect(() => bracket.resetMatchResults(0)).toThrow("The match is locked.");
});
});
describe("Engine data immutability", () => {
test("engine operations return new data and leave the input untouched", () => {
const initial = createResolved({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
const snapshot = structuredClone(initial);
const afterReport = Engine.reportResult(initial, {
matchId: 0,
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
expect(initial).toEqual(snapshot);
expect(afterReport.data.match[0].opponent1?.result).toBe("win");
const afterReset = Engine.resetMatchResults(afterReport.data, 0);
expect(afterReport.data.match[0].opponent1?.result).toBe("win");
expect(afterReset.data.match[0].opponent1?.result).toBe(undefined);
});
test("changedMatches contains only genuinely changed rows", () => {
const initial = createResolved({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
const afterReport = Engine.reportResult(initial, {
matchId: 0,
opponent1: { score: 16, result: "win" },
opponent2: { score: 12 },
});
const changedIds = afterReport.changedMatches.map((match) => match.id);
expect(changedIds).toContain(0); // The reported match.
expect(changedIds).toContain(2); // The final receiving the winner.
expect(changedIds).not.toContain(1); // The other semi is untouched.
});
});