mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
Fix even even even more bugs
This commit is contained in:
34
app/features/api-public/routes/calendar.$year.$week.test.ts
Normal file
34
app/features/api-public/routes/calendar.$year.$week.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { wrappedLoader } from "~/utils/Test";
|
||||
import type { GetCalendarWeekResponse } from "../schema";
|
||||
import { loader } from "./calendar.$year.$week";
|
||||
|
||||
const weekLoader = wrappedLoader<Response>({ loader });
|
||||
|
||||
const fetchWeek = async (year: number, week: number) => {
|
||||
const response = await weekLoader({
|
||||
params: { year: String(year), week: String(week) },
|
||||
});
|
||||
|
||||
return (await response.json()) as GetCalendarWeekResponse;
|
||||
};
|
||||
|
||||
describe("GET /api/calendar/:year/:week", () => {
|
||||
test("an event starting exactly at the week boundary is returned for exactly one week", async () => {
|
||||
const user = await UserFactory.createRegular();
|
||||
// Monday 2025-01-13 00:00 UTC, i.e. Sunday 7 PM EST — the boundary
|
||||
// between ISO weeks 2 and 3 of 2025
|
||||
await CalendarEventFactory.create({
|
||||
authorId: user.id,
|
||||
startTimes: [dateToDatabaseTimestamp(new Date("2025-01-13T00:00:00Z"))],
|
||||
});
|
||||
|
||||
const weekTwoEvents = await fetchWeek(2025, 2);
|
||||
const weekThreeEvents = await fetchWeek(2025, 3);
|
||||
|
||||
expect(weekTwoEvents.length + weekThreeEvents.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,7 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
|
||||
">=",
|
||||
dateToDatabaseTimestamp(startTime),
|
||||
)
|
||||
.where("CalendarEventDate.startsAt", "<=", dateToDatabaseTimestamp(endTime))
|
||||
.where("CalendarEventDate.startsAt", "<", dateToDatabaseTimestamp(endTime))
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy("CalendarEventDate.startsAt", "asc")
|
||||
.execute();
|
||||
|
||||
@@ -17,6 +17,16 @@ describe("chatCodeVisible", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("not visible when just past expiration window", () => {
|
||||
const result = chatAccessible({
|
||||
isStaff: false,
|
||||
expiresAfterDays: 1,
|
||||
comparedTo: sub(new Date(), { days: 1, hours: 12 }),
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("not visible when past expiration window", () => {
|
||||
const result = chatAccessible({
|
||||
isStaff: false,
|
||||
|
||||
@@ -12,7 +12,7 @@ export function chatAccessible(args: {
|
||||
}): boolean {
|
||||
const extraDays = args.isStaff ? STAFF_EXTRA_DAYS : 0;
|
||||
return (
|
||||
differenceInDays(new Date(), args.comparedTo) <=
|
||||
differenceInDays(new Date(), args.comparedTo) <
|
||||
args.expiresAfterDays + extraDays
|
||||
);
|
||||
}
|
||||
|
||||
24
app/features/lfg/core/filtering.test.ts
Normal file
24
app/features/lfg/core/filtering.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { LFGLoaderPost } from "../routes/lfg";
|
||||
import { filterPosts } from "./filtering";
|
||||
|
||||
const postOfType = (type: LFGLoaderPost["type"]) =>
|
||||
({
|
||||
type,
|
||||
author: { weaponPool: [] },
|
||||
team: null,
|
||||
}) as unknown as LFGLoaderPost;
|
||||
|
||||
describe("filterPosts", () => {
|
||||
test("a weapon filter with no weapons selected shows every post", () => {
|
||||
const posts = [postOfType("PLAYER_FOR_TEAM"), postOfType("COACH_FOR_TEAM")];
|
||||
|
||||
const filtered = filterPosts(
|
||||
posts,
|
||||
[{ _tag: "Weapon", weaponSplIds: [] }],
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ function filterMatchesPost(
|
||||
if (post.type === "COACH_FOR_TEAM") {
|
||||
// not visible in the UI
|
||||
if (
|
||||
filter._tag === "Weapon" ||
|
||||
(filter._tag === "Weapon" && filter.weaponSplIds.length > 0) ||
|
||||
filter._tag === "MaxTier" ||
|
||||
filter._tag === "MinTier"
|
||||
) {
|
||||
|
||||
@@ -14,12 +14,16 @@ const plusTierFilter: LFGFilter = { _tag: "PlusTier", tier: 1 };
|
||||
const maxTierFilter: LFGFilter = { _tag: "MaxTier", tier: "GOLD" };
|
||||
const minTierFilter: LFGFilter = { _tag: "MinTier", tier: "BRONZE" };
|
||||
|
||||
// the filter LFGAddFilterButton inserts when the user picks "Weapon"
|
||||
const emptyWeaponFilter: LFGFilter = { _tag: "Weapon", weaponSplIds: [] };
|
||||
|
||||
describe("lfgSearchParams", () => {
|
||||
it("round-trips", () => {
|
||||
assertRoundTrips(lfgSearchParams, {
|
||||
q: [
|
||||
[],
|
||||
[weaponFilter],
|
||||
[emptyWeaponFilter],
|
||||
[typeFilter],
|
||||
[timezoneFilter],
|
||||
[languageFilter],
|
||||
|
||||
@@ -92,15 +92,16 @@ export function filterToSmallStr(filter: LFGFilter): string {
|
||||
|
||||
export function smallStrToFilter(s: string): LFGFilter | null {
|
||||
const [tag, val] = s.split(".");
|
||||
if (!tag || !val) return null;
|
||||
if (!tag || val === undefined) return null;
|
||||
|
||||
switch (tag) {
|
||||
case "w": {
|
||||
// an empty weapon filter is valid, it's what the add filter button inserts
|
||||
const weaponIds = val
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((x) => Number.parseInt(x, 10) as MainWeaponId)
|
||||
.filter((x) => x !== null && x !== undefined);
|
||||
if (weaponIds.length === 0) return null;
|
||||
.filter((x) => !Number.isNaN(x));
|
||||
return {
|
||||
_tag: "Weapon",
|
||||
weaponSplIds: weaponIds,
|
||||
|
||||
@@ -91,6 +91,71 @@ describe("swiss standings - losses against tied", () => {
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round
|
||||
});
|
||||
|
||||
it("should ignore a dropped out team with an identical record (losses against tied)", () => {
|
||||
const data = Engine.create({
|
||||
type: "swiss",
|
||||
seeding: [1, 2, 3, 4, 5, 6],
|
||||
settings: { groupCount: 1, roundCount: 3 },
|
||||
});
|
||||
|
||||
const playedMatch = (
|
||||
id: number,
|
||||
roundIdx: number,
|
||||
number: number,
|
||||
winnerId: number,
|
||||
loserId: number,
|
||||
): MatchData => ({
|
||||
id,
|
||||
stageId: data.stage[0].id,
|
||||
groupId: data.group[0].id,
|
||||
roundId: data.round[roundIdx].id,
|
||||
number,
|
||||
opponent1: { id: winnerId },
|
||||
opponent2: { id: loserId },
|
||||
winnerSide: "opponent1",
|
||||
});
|
||||
|
||||
// teams 1 and 6 both finish 2-1; team 1's only loss is to team 6,
|
||||
// who dropped out after the swiss ended
|
||||
data.match = [
|
||||
playedMatch(0, 0, 1, 1, 2),
|
||||
playedMatch(1, 0, 2, 3, 4),
|
||||
playedMatch(2, 0, 3, 5, 6),
|
||||
playedMatch(3, 1, 1, 1, 3),
|
||||
playedMatch(4, 1, 2, 2, 5),
|
||||
playedMatch(5, 1, 3, 6, 4),
|
||||
playedMatch(6, 2, 1, 6, 1),
|
||||
playedMatch(7, 2, 2, 3, 5),
|
||||
playedMatch(8, 2, 3, 2, 4),
|
||||
];
|
||||
|
||||
const tournament = testTournament({
|
||||
data,
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "swiss",
|
||||
name: "Main Bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: [1, 2, 3, 4, 5, 6].map((teamId) =>
|
||||
tournamentCtxTeam(teamId, { droppedOut: teamId === 6 ? 1 : 0 }),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const standing = tournament
|
||||
.bracketByIdx(0)
|
||||
?.standings.find((standing) => standing.team.id === 1);
|
||||
invariant(standing, "Standing not found");
|
||||
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(0);
|
||||
});
|
||||
|
||||
const inProgressSwissTestTournament = () => {
|
||||
const data = Engine.create({
|
||||
type: "swiss",
|
||||
|
||||
@@ -34,7 +34,9 @@ export class SwissBracket extends Bracket {
|
||||
|
||||
const relevantMatchesFinished = this.standingsAreFinal;
|
||||
|
||||
if (advanceThreshold) {
|
||||
// explicit placements override the threshold, e.g. a consolation
|
||||
// bracket for the teams that did not advance
|
||||
if (advanceThreshold && placements.length === 0) {
|
||||
return {
|
||||
relevantMatchesFinished,
|
||||
teams: standings
|
||||
@@ -316,10 +318,17 @@ export class SwissBracket extends Bracket {
|
||||
});
|
||||
}
|
||||
|
||||
// wins against tied
|
||||
const droppedOutTeams = this.tournament.ctx.teams
|
||||
.filter((t) => t.droppedOut)
|
||||
.map((t) => t.id);
|
||||
|
||||
// wins against tied, results against dropped out teams don't count
|
||||
for (const team of teams) {
|
||||
if (droppedOutTeams.includes(team.id)) continue;
|
||||
|
||||
for (const team2 of teams) {
|
||||
if (team.id === team2.id) continue;
|
||||
if (droppedOutTeams.includes(team2.id)) continue;
|
||||
if (
|
||||
team.setWins !== team2.setWins ||
|
||||
// check also set losses to account for dropped teams
|
||||
@@ -358,9 +367,6 @@ export class SwissBracket extends Bracket {
|
||||
}
|
||||
}
|
||||
|
||||
const droppedOutTeams = this.tournament.ctx.teams
|
||||
.filter((t) => t.droppedOut)
|
||||
.map((t) => t.id);
|
||||
placements.push(
|
||||
...teams
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it, test } from "vitest";
|
||||
import type { MatchData } from "~/features/tournament-bracket/core/engine/types";
|
||||
import type {
|
||||
BracketData,
|
||||
GeneratedRound,
|
||||
MatchData,
|
||||
} from "~/features/tournament-bracket/core/engine/types";
|
||||
import { unwrap } from "~/utils/result";
|
||||
import * as Engine from "./engine";
|
||||
import type * as Progression from "./Progression";
|
||||
import { Tournament } from "./Tournament";
|
||||
import {
|
||||
IN_THE_ZONE_32,
|
||||
@@ -498,6 +505,101 @@ describe("Resolving the team a user is a member of", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamMemberOfProgressStatus in swiss", () => {
|
||||
const teamsWithMembers = [1, 2, 3, 4].map((teamId) =>
|
||||
tournamentCtxTeam(teamId, { memberUserIds: [100 + teamId] }),
|
||||
);
|
||||
|
||||
it("resolves an early advanced team as waiting for the follow-up bracket", () => {
|
||||
const data = playOutEarlyAdvanceSwiss(progressions.swissEarlyAdvance);
|
||||
|
||||
const tournament = testTournament({
|
||||
data,
|
||||
ctx: {
|
||||
settings: { bracketProgression: progressions.swissEarlyAdvance },
|
||||
teams: teamsWithMembers,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
tournament.bracketByIdx(1)?.seeding,
|
||||
"test setup: the advanced team should be in the top cut preview",
|
||||
).toContain(1);
|
||||
expect(tournament.teamMemberOfProgressStatus({ id: 101 })?.type).toBe(
|
||||
"WAITING_FOR_BRACKET",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a dropped out team's status as thanks for playing", () => {
|
||||
const data = Engine.create({
|
||||
type: "swiss",
|
||||
seeding: [1, 2, 3, 4],
|
||||
settings: {},
|
||||
});
|
||||
finishPendingMatches(data);
|
||||
|
||||
const tournament = testTournament({
|
||||
data,
|
||||
ctx: {
|
||||
settings: { bracketProgression: progressions.swissOneGroup },
|
||||
teams: [1, 2, 3, 4].map((teamId) =>
|
||||
tournamentCtxTeam(teamId, {
|
||||
memberUserIds: [100 + teamId],
|
||||
droppedOut: teamId === 4 ? 1 : 0,
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(tournament.teamMemberOfProgressStatus({ id: 104 })?.type).toBe(
|
||||
"THANKS_FOR_PLAYING",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Swiss early advance bracket sourcing", () => {
|
||||
const progressionWithConsolation: Progression.ParsedBracket[] = [
|
||||
{
|
||||
name: "Main Bracket",
|
||||
type: "swiss",
|
||||
requiresCheckIn: false,
|
||||
settings: { advanceThreshold: 3 },
|
||||
},
|
||||
{
|
||||
name: "Top Cut",
|
||||
type: "single_elimination",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [] }],
|
||||
},
|
||||
{
|
||||
name: "Consolation",
|
||||
type: "single_elimination",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [2, 3, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
it("sources a consolation bracket by its placements instead of the advance threshold", () => {
|
||||
const data = playOutEarlyAdvanceSwiss(progressionWithConsolation);
|
||||
|
||||
const tournament = testTournament({
|
||||
data,
|
||||
ctx: { settings: { bracketProgression: progressionWithConsolation } },
|
||||
});
|
||||
|
||||
expect(
|
||||
tournament.bracketByIdx(1)?.seeding,
|
||||
"test setup: the swiss winner should be in the top cut",
|
||||
).toContain(1);
|
||||
expect(
|
||||
tournament.bracketByIdx(2)?.seeding,
|
||||
"the swiss winner advanced to the top cut and should not also be in the consolation bracket",
|
||||
).not.toContain(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamById division seeds", () => {
|
||||
it("assigns unique seeds within a division when a late registrant has null startingBracketIdx", () => {
|
||||
const tournament = testTournament({
|
||||
@@ -540,3 +642,62 @@ describe("teamById division seeds", () => {
|
||||
expect(new Set(divATeamSeeds).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Plays a 4 team, 5 round, advance threshold 3 swiss all the way to its end.
|
||||
* Team 1 wins rounds 1-3 locking their top cut spot, after which the pairing
|
||||
* excludes them from the remaining rounds.
|
||||
*/
|
||||
function playOutEarlyAdvanceSwiss(
|
||||
progression: Progression.ParsedBracket[],
|
||||
): BracketData {
|
||||
const data = Engine.create({
|
||||
type: "swiss",
|
||||
seeding: [1, 2, 3, 4],
|
||||
settings: { advanceThreshold: 3 },
|
||||
});
|
||||
const groupId = data.group[0].id;
|
||||
|
||||
finishPendingMatches(data);
|
||||
for (let roundNumber = 2; roundNumber <= 5; roundNumber++) {
|
||||
const bracket = testTournament({
|
||||
data,
|
||||
ctx: { settings: { bracketProgression: progression } },
|
||||
}).bracketByIdx(0)!;
|
||||
const generated = Engine.generateRound(bracket.data, {
|
||||
groupId,
|
||||
standings: bracket.standings,
|
||||
settings: bracket.settings,
|
||||
});
|
||||
if (!generated.ok) break;
|
||||
appendGeneratedRound(data, unwrap(generated));
|
||||
finishPendingMatches(data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Finishes every pending match, team 1 always winning theirs and otherwise the home side. */
|
||||
function finishPendingMatches(data: BracketData) {
|
||||
for (const match of data.match) {
|
||||
if (match.winnerSide !== null || !match.opponent2) continue;
|
||||
|
||||
match.winnerSide = match.opponent2.id === 1 ? "opponent2" : "opponent1";
|
||||
}
|
||||
}
|
||||
|
||||
function appendGeneratedRound(data: BracketData, round: GeneratedRound) {
|
||||
let id = Math.max(...data.match.map((match) => match.id)) + 1;
|
||||
for (const match of round.matches) {
|
||||
data.match.push({
|
||||
id: id++,
|
||||
stageId: data.stage[0].id,
|
||||
groupId: round.groupId,
|
||||
roundId: round.roundId,
|
||||
number: match.number,
|
||||
opponent1: match.opponent1,
|
||||
opponent2: match.opponent2,
|
||||
winnerSide: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { testTournament } from "./tests/test-utils";
|
||||
|
||||
// in its own file because changing the process timezone affects every Date
|
||||
// operation of the worker while these tests run
|
||||
describe("regularCheckInStartsAt in a DST observing timezone", () => {
|
||||
const ORIGINAL_TZ = process.env.TZ;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "America/New_York";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_TZ === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = ORIGINAL_TZ;
|
||||
}
|
||||
});
|
||||
|
||||
it("check-in opens one hour of real time before the start also on the spring forward night", () => {
|
||||
// 3:30 AM EDT on the night the USA moves to daylight saving time
|
||||
const startsAt = new Date("2025-03-09T07:30:00Z");
|
||||
|
||||
const tournament = testTournament({
|
||||
ctx: { startsAt: dateToDatabaseTimestamp(startsAt) },
|
||||
});
|
||||
|
||||
expect(tournament.regularCheckInStartsAt.getTime()).toBe(
|
||||
startsAt.getTime() - 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("check-in opens one hour of real time before the start also on the fall back night", () => {
|
||||
// 1:30 AM EST on the night the USA moves off daylight saving time
|
||||
const startsAt = new Date("2025-11-02T06:30:00Z");
|
||||
|
||||
const tournament = testTournament({
|
||||
ctx: { startsAt: dateToDatabaseTimestamp(startsAt) },
|
||||
});
|
||||
|
||||
expect(tournament.regularCheckInStartsAt.getTime()).toBe(
|
||||
startsAt.getTime() - 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { logger } from "~/utils/logger";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { groupNumberToLetters } from "../tournament-bracket-utils";
|
||||
import { type Bracket, createBracket } from "./Bracket";
|
||||
import { calculateTeamStatus } from "./engine/swiss/team-status";
|
||||
import { getRounds } from "./rounds";
|
||||
import * as Seeding from "./Seeding";
|
||||
import type { TournamentData } from "./Tournament.server";
|
||||
@@ -855,9 +856,9 @@ export class Tournament {
|
||||
|
||||
/** Date when the regular check-in is scheduled to start. */
|
||||
get regularCheckInStartsAt() {
|
||||
const result = new Date(this.ctx.startsAt);
|
||||
result.setMinutes(result.getMinutes() - 60);
|
||||
return result;
|
||||
// elapsed time math instead of wall clock math so that the window
|
||||
// stays one hour long across a DST transition
|
||||
return new Date(this.ctx.startsAt.getTime() - 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/** Date when the regular check-in is scheduled to start. */
|
||||
@@ -1176,20 +1177,32 @@ export class Tournament {
|
||||
|
||||
for (const bracket of startedBrackets) {
|
||||
if (bracket.type !== "swiss") continue;
|
||||
// dropped out teams and teams whose run ended early via the advance
|
||||
// threshold are excluded from the pairing, so no round is coming for them
|
||||
if (bracket.everyMatchOver || team.droppedOut) continue;
|
||||
|
||||
// TODO: both seeding and participantTournamentTeamIds are used for the same thing
|
||||
const isParticipant = bracket.participantTournamentTeamIds.includes(
|
||||
team.id,
|
||||
);
|
||||
|
||||
const setsGeneratedCount = bracket.data.match.filter(
|
||||
const teamsMatches = bracket.data.match.filter(
|
||||
(match) =>
|
||||
match.opponent1?.id === team.id || match.opponent2?.id === team.id,
|
||||
).length;
|
||||
);
|
||||
const notAllRoundsGenerated =
|
||||
setsGeneratedCount !== bracket.swissRoundCount;
|
||||
teamsMatches.length !== bracket.swissRoundCount;
|
||||
|
||||
if (isParticipant && notAllRoundsGenerated) {
|
||||
const advanceThreshold = bracket.settings?.advanceThreshold;
|
||||
const runEndedEarly = advanceThreshold
|
||||
? calculateTeamStatus({
|
||||
...swissTeamRecord(teamsMatches, team.id),
|
||||
advanceThreshold,
|
||||
roundCount: bracket.swissRoundCount,
|
||||
}) !== "active"
|
||||
: false;
|
||||
|
||||
if (isParticipant && notAllRoundsGenerated && !runEndedEarly) {
|
||||
return { type: "WAITING_FOR_ROUND" } as const;
|
||||
}
|
||||
}
|
||||
@@ -1446,6 +1459,36 @@ export class Tournament {
|
||||
}
|
||||
}
|
||||
|
||||
/** A team's swiss set record off its match data, a BYE counting as a win. */
|
||||
function swissTeamRecord(matches: MatchData[], teamId: number) {
|
||||
let wins = 0;
|
||||
let losses = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
const side =
|
||||
match.opponent1?.id === teamId
|
||||
? "opponent1"
|
||||
: match.opponent2?.id === teamId
|
||||
? "opponent2"
|
||||
: null;
|
||||
if (!side) continue;
|
||||
|
||||
if (!match.opponent1 || !match.opponent2) {
|
||||
wins++;
|
||||
continue;
|
||||
}
|
||||
if (!match.winnerSide) continue;
|
||||
|
||||
if (match.winnerSide === side) {
|
||||
wins++;
|
||||
} else {
|
||||
losses++;
|
||||
}
|
||||
}
|
||||
|
||||
return { wins, losses };
|
||||
}
|
||||
|
||||
/** The parts of a tournament that decide who organizes it. */
|
||||
export type TournamentOrganizerCtx = Pick<
|
||||
TournamentData["ctx"],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
|
||||
import { seedOrgEventWithParticipants } from "./test-utils";
|
||||
|
||||
@@ -59,6 +61,51 @@ describe("findByUserId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findEventsByMonth", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(1);
|
||||
});
|
||||
|
||||
const seedOrgEventAt = async (startTime: Date) => {
|
||||
const org = await TournamentOrganizationFactory.create({
|
||||
ownerId: users.id(1),
|
||||
});
|
||||
await CalendarEventFactory.create({
|
||||
authorId: users.id(1),
|
||||
organizationId: org.id,
|
||||
startTimes: [dateToDatabaseTimestamp(startTime)],
|
||||
});
|
||||
|
||||
return org;
|
||||
};
|
||||
|
||||
test("includes an event starting within the timezone margin before the month", async () => {
|
||||
const org = await seedOrgEventAt(new Date("2024-12-31T22:00:00Z"));
|
||||
|
||||
const events = await TournamentOrganizationRepository.findEventsByMonth({
|
||||
month: 0,
|
||||
year: 2025,
|
||||
organizationId: org.id,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("includes an event starting within the timezone margin after the month", async () => {
|
||||
// Jan 31, 6 PM in America/Los_Angeles — the org page calendar renders
|
||||
// this into the January grid for viewers west of UTC
|
||||
const org = await seedOrgEventAt(new Date("2025-02-01T02:00:00Z"));
|
||||
|
||||
const events = await TournamentOrganizationRepository.findEventsByMonth({
|
||||
month: 0,
|
||||
year: 2025,
|
||||
organizationId: org.id,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countActiveParticipants", () => {
|
||||
const WINDOW_START = 1_700_000_000;
|
||||
const WINDOW_END = WINDOW_START + 60 * 60 * 24 * 31;
|
||||
|
||||
@@ -359,11 +359,11 @@ export async function findEventsByMonth({
|
||||
organizationId,
|
||||
}: FindEventsByMonthArgs) {
|
||||
const firstDayOfTheMonth = new Date(Date.UTC(year, month, 1));
|
||||
const lastDayOfTheMonth = new Date(Date.UTC(year, month + 1, 0));
|
||||
const firstDayOfTheNextMonth = new Date(Date.UTC(year, month + 1, 1));
|
||||
|
||||
// a bit of margin for timezones, filtered in the frontend code
|
||||
firstDayOfTheMonth.setUTCDate(firstDayOfTheMonth.getUTCDate() - 1);
|
||||
lastDayOfTheMonth.setUTCDate(lastDayOfTheMonth.getUTCDate() + 1);
|
||||
firstDayOfTheNextMonth.setUTCDate(firstDayOfTheNextMonth.getUTCDate() + 1);
|
||||
|
||||
const events = await findEventsBaseQuery(organizationId)
|
||||
.where(
|
||||
@@ -374,7 +374,7 @@ export async function findEventsByMonth({
|
||||
.where(
|
||||
"CalendarEventDate.startsAt",
|
||||
"<=",
|
||||
dateToDatabaseTimestamp(lastDayOfTheMonth),
|
||||
dateToDatabaseTimestamp(firstDayOfTheNextMonth),
|
||||
)
|
||||
.orderBy("CalendarEventDate.startsAt", "asc")
|
||||
.execute();
|
||||
|
||||
@@ -21,6 +21,11 @@ describe("pathnameFromPotentialURL()", () => {
|
||||
"FW4dKrY",
|
||||
);
|
||||
});
|
||||
|
||||
test("Resolves a scheme-less URL paste to the path", () => {
|
||||
// otherwise the generated discordUrl becomes https://discord.gg/discord.gg/FW4dKrY
|
||||
expect(pathnameFromPotentialURL("discord.gg/FW4dKrY")).toBe("FW4dKrY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateBySentence()", () => {
|
||||
@@ -53,6 +58,17 @@ describe("truncateBySentence()", () => {
|
||||
const text = "First line\nSecond line\nThird line";
|
||||
expect(truncateBySentence(text, 20)).toBe("First line");
|
||||
});
|
||||
|
||||
test("Does not treat a period inside a time like 18.00 as a sentence end", () => {
|
||||
const text =
|
||||
"Doors at 18.00, we will be playing five rounds of swiss followed by a top cut.";
|
||||
expect(truncateBySentence(text, 40)).not.toBe("Doors at 18.");
|
||||
});
|
||||
|
||||
test("Does not return a tiny fraction of the budget when a long sentence follows a short one", () => {
|
||||
const text = `Hi. ${"x".repeat(400)}. Everyone is welcome.`;
|
||||
expect(truncateBySentence(text, 300)).not.toBe("Hi.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMarkdown()", () => {
|
||||
@@ -71,6 +87,11 @@ describe("removeMarkdown()", () => {
|
||||
expect(removeMarkdown("café & tea")).toBe("café & tea");
|
||||
});
|
||||
|
||||
test("Does not throw on an out of range numeric entity", () => {
|
||||
// e.g. an organizer writing a hex color code in the description
|
||||
expect(() => removeMarkdown("background: � here")).not.toThrow();
|
||||
});
|
||||
|
||||
test("Leaves unknown named entities untouched", () => {
|
||||
expect(removeMarkdown("AT&T &fakeentity; rules")).toBe(
|
||||
"AT&T &fakeentity; rules",
|
||||
|
||||
@@ -50,19 +50,38 @@ export function gearTypeToInitial(gearType: GearType) {
|
||||
}
|
||||
|
||||
export function pathnameFromPotentialURL(maybeUrl: string) {
|
||||
const parsed = safeParseUrl(maybeUrl);
|
||||
if (parsed) return stripEdgeSlashes(parsed.pathname);
|
||||
|
||||
// handle a URL pasted without a protocol, e.g. "discord.gg/FW4dKrY"
|
||||
const parsedWithProtocol = safeParseUrl(`https://${maybeUrl}`);
|
||||
const pathname = parsedWithProtocol
|
||||
? stripEdgeSlashes(parsedWithProtocol.pathname)
|
||||
: "";
|
||||
|
||||
return pathname || maybeUrl;
|
||||
}
|
||||
|
||||
function safeParseUrl(value: string) {
|
||||
try {
|
||||
return new URL(maybeUrl).pathname.replace(/^\/+|\/+$/g, "");
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return maybeUrl;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripEdgeSlashes(pathname: string) {
|
||||
return pathname.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
export function truncateBySentence(value: string, max: number) {
|
||||
if (value.length <= max) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const sentences = value.match(/[^.!?\n]+[.!?\n]*/g) || [];
|
||||
// a sentence only ends at a terminator followed by whitespace, so that
|
||||
// e.g. "18.00" does not split in the middle
|
||||
const sentences = value.match(/[\s\S]+?(?:[.!?](?=\s|$)|\n|$)/g) || [];
|
||||
let result = "";
|
||||
|
||||
for (const sentence of sentences) {
|
||||
@@ -72,7 +91,13 @@ export function truncateBySentence(value: string, max: number) {
|
||||
result += sentence;
|
||||
}
|
||||
|
||||
return result.length > 0 ? result.trim() : value.slice(0, max).trim();
|
||||
// when cutting at a sentence boundary would leave most of the budget
|
||||
// unused, a mid-sentence cut that fills it is more informative
|
||||
if (result.length < max / 2) {
|
||||
return value.slice(0, max).trim();
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
// based on https://github.com/zuchka/remove-markdown
|
||||
@@ -101,9 +126,9 @@ export function removeMarkdown(value: string) {
|
||||
const codePoint = code.startsWith("x")
|
||||
? Number.parseInt(code.slice(1), 16)
|
||||
: Number.parseInt(code, 10);
|
||||
return Number.isFinite(codePoint)
|
||||
? String.fromCodePoint(codePoint)
|
||||
: "";
|
||||
const isValidCodePoint =
|
||||
Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff;
|
||||
return isValidCodePoint ? String.fromCodePoint(codePoint) : "";
|
||||
})
|
||||
// Remove setext-style headers
|
||||
.replace(/^[=-]{2,}\s*$/g, "")
|
||||
|
||||
Reference in New Issue
Block a user