Files
sendou.ink/app/features/tournament-bracket/BracketRepository.server.test.ts
2026-09-09 21:37:21 +03:00

206 lines
6.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import type { TournamentSettings } from "~/db/tables-json";
import { userChannel } from "~/features/events/events-types";
import {
abortSubscriptions,
subscribeTo,
} from "~/features/events/tests/fixtures";
import { resolveMatchMapList } from "~/features/tournament-match/core/mapList.server";
import { reportScore } from "~/features/tournament-match/core/reportScore.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { invariant } from "~/utils/invariant";
import * as BracketRepository from "./BracketRepository.server";
import * as Engine from "./core/engine";
import { executeBracketOperation } from "./core/executeBracketOperation.server";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "./core/Tournament.server";
const users = UserFactory.pool();
beforeEach(async () => {
await users.create(9);
});
afterEach(() => {
abortSubscriptions();
});
/** KOs are only reported in round robin brackets. */
const ROUND_ROBIN: TournamentSettings["bracketProgression"] = [
{
name: "Groups",
type: "round_robin",
requiresCheckIn: false,
settings: { teamsPerGroup: 2 },
},
];
const setupStartedMatch = async (
overrides?: Partial<Parameters<typeof TournamentFactory.create>[0]>,
) => {
const authorId = users.id(1);
const teamAlphaUserIds = [users.id(2), users.id(3), users.id(4), users.id(5)];
const teamBravoUserIds = [users.id(6), users.id(7), users.id(8), users.id(9)];
const tournament = await TournamentFactory.create({ authorId, ...overrides });
for (const memberUserIds of [teamAlphaUserIds, teamBravoUserIds]) {
await TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds },
{ isCheckedIn: true },
);
}
await TournamentFactory.startBracket(tournament.id);
const match = await db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select(["TournamentMatch.id", "TournamentMatch.chatRoomId"])
.where("TournamentStage.tournamentId", "=", tournament.id)
.where("TournamentMatch.chatRoomId", "is not", null)
.executeTakeFirstOrThrow();
return {
tournamentId: tournament.id,
authorId,
matchId: match.id,
chatRoomId: match.chatRoomId!,
};
};
type GameResult = { winner: "one" | "two"; ko: boolean };
/** Reports every game of the match through `reportScore` until the set is over. */
const playOutMatch = async (
setup: Awaited<ReturnType<typeof setupStartedMatch>>,
resultOfGame: (position: number) => GameResult = () => ({
winner: "one",
ko: false,
}),
) => {
let position = 0;
let setOver = false;
while (!setOver) {
const tournament = await tournamentFromDB(setup.tournamentId);
const matchRow = await TournamentMatchRepository.findMatchById(
setup.matchId,
);
invariant(matchRow, "Match not found");
invariant(matchRow.opponentOne?.id, "Match has no first opponent");
invariant(matchRow.opponentTwo?.id, "Match has no second opponent");
const { winner, ko } = resultOfGame(position);
const reported = await reportScore({
match: matchRow,
tournament,
mapList: await resolveMatchMapList({ match: matchRow, tournament }),
user: { id: setup.authorId },
position,
winnerTeamId:
winner === "one" ? matchRow.opponentOne.id : matchRow.opponentTwo.id,
ko,
});
invariant(reported, `Game ${position} was already reported`);
setOver = reported.setOver;
position++;
}
clearTournamentDataCache(setup.tournamentId);
};
describe("BracketRepository.applyMatchChanges", () => {
test("completing a match marks its chat room inactive", async () => {
const setup = await setupStartedMatch();
expect((await roomById(setup.chatRoomId)).inactive).toBe(0);
await playOutMatch(setup);
expect((await roomById(setup.chatRoomId)).inactive).toBe(1);
});
test("tells the match's participants their room list changed", async () => {
const setup = await setupStartedMatch();
const received = subscribeTo(userChannel(users.id(2)));
await playOutMatch(setup);
await vi.waitFor(() =>
expect(received).toEqual([{ kind: "roomsChanged" }]),
);
});
test("reopening a completed match marks its chat room back active", async () => {
const setup = await setupStartedMatch();
await playOutMatch(setup);
const tournament = await tournamentFromDB(setup.tournamentId);
await executeBracketOperation({
tournamentId: setup.tournamentId,
tournament,
operation: (bracketData) =>
Engine.reopenMatch(bracketData, setup.matchId),
endDroppedTeams: false,
});
expect((await roomById(setup.chatRoomId)).inactive).toBe(0);
});
});
describe("BracketRepository.findByTournamentId", () => {
test("counts each opponent's KO wins into totalKos", async () => {
const setup = await setupStartedMatch({ bracketProgression: ROUND_ROBIN });
const kosOfGame: Array<GameResult> = [
{ winner: "two", ko: true },
{ winner: "one", ko: true },
{ winner: "one", ko: true },
];
await playOutMatch(setup, (position) => kosOfGame[position]);
const { match } = await BracketRepository.findByTournamentId(
setup.tournamentId,
);
const playedMatch = match.find(
(bracketMatch) => bracketMatch.id === setup.matchId,
);
expect(playedMatch?.opponent1?.totalKos).toBe(2);
expect(playedMatch?.opponent2?.totalKos).toBe(1);
});
test("does not count games won without a KO", async () => {
const setup = await setupStartedMatch({ bracketProgression: ROUND_ROBIN });
await playOutMatch(setup);
const { match } = await BracketRepository.findByTournamentId(
setup.tournamentId,
);
const playedMatch = match.find(
(bracketMatch) => bracketMatch.id === setup.matchId,
);
expect(playedMatch?.opponent1?.totalKos).toBe(0);
expect(playedMatch?.opponent2?.totalKos).toBe(0);
});
});
const roomById = (id: number) =>
db
.selectFrom("ChatRoom")
.selectAll()
.where("id", "=", id)
.executeTakeFirstOrThrow();