Fix even even more bugs

This commit is contained in:
Kalle
2026-08-05 15:36:25 +03:00
parent cb8577d224
commit e65dd86683
9 changed files with 181 additions and 26 deletions

View File

@@ -0,0 +1,48 @@
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { RelativeTime } from "./RelativeTime";
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
timeZoneName: "short",
};
function renderRelativeTime(timestamp: number) {
const router = createMemoryRouter(
[
{
path: "/",
element: <RelativeTime timestamp={timestamp}>3 days ago</RelativeTime>,
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
function expectedTitle(timestamp: number) {
const language =
navigator.languages.find(
(lang) => lang.split("-")[0].toLowerCase() === "en",
) ?? "en";
return new Intl.DateTimeFormat(language, FORMAT_OPTIONS).format(timestamp);
}
describe("RelativeTime", () => {
test("tooltip shows the date the millisecond timestamp points to", async () => {
const timestamp = new Date("2025-08-02T12:00:00Z").getTime();
const screen = await renderRelativeTime(timestamp);
const abbr = screen.getByText("3 days ago");
await expect.element(abbr).toBeVisible();
expect(abbr.element().getAttribute("title")).toBe(expectedTitle(timestamp));
});
});

View File

@@ -17,6 +17,8 @@ export function RelativeTime({
});
return (
<abbr title={formatter.format(timestamp) ?? undefined}>{children}</abbr>
<abbr title={formatter.format(new Date(timestamp)) ?? undefined}>
{children}
</abbr>
);
}

View File

@@ -972,6 +972,70 @@ describe("single elimination source - underground", () => {
});
});
describe("swiss between rounds", () => {
const SWISS_MAIN_BRACKET = {
type: "swiss" as const,
name: "Main Bracket",
requiresCheckIn: false,
settings: { groupCount: 1, roundCount: 5 },
sources: [],
};
// swiss with round 1 fully reported but rounds 2-5 not yet paired
const betweenRoundsSwissData = () => {
let data = Engine.create({
type: "swiss",
seeding: [1, 2, 3, 4],
settings: { groupCount: 1, roundCount: 5 },
});
// needed to make it "not preview"
data.round = data.round.map((r) => ({
...r,
maps: { count: 3, type: "BEST_OF" },
}));
for (const match of data.match) {
data = reportLowerIdWinner(data, match.id);
}
return data;
};
it("tournament is not over while swiss still has unpaired rounds", () => {
const tournament = testTournament({
ctx: {
settings: { bracketProgression: [SWISS_MAIN_BRACKET] },
},
data: betweenRoundsSwissData(),
});
expect(tournament.everyBracketOver).toBe(false);
});
it("can't finalize between swiss rounds when progression also has an underground bracket", () => {
const tournament = testTournament({
ctx: {
settings: {
bracketProgression: [
SWISS_MAIN_BRACKET,
{
type: "single_elimination" as const,
name: "Underground Bracket",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [3, 4] }],
},
],
},
},
data: betweenRoundsSwissData(),
});
expect(tournament.canFinalize({ id: 1 })).toBe(false);
});
});
function reportLowerIdWinner(data: BracketData, matchId: number): BracketData {
const match = matchById(data, matchId);
const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!;

View File

@@ -77,8 +77,8 @@ export class SwissBracket extends Bracket {
* Exception being rounds that can never be paired because every team of the group has already
* advanced or been eliminated (early advance variation).
*/
get standingsAreFinal() {
if (!this.everyMatchOver) return false;
get everyMatchOver() {
if (!super.everyMatchOver) return false;
return this.data.group.every((group) => {
const groupsMatches = this.data.match.filter(

View File

@@ -40,8 +40,6 @@ export type BracketDerivedMeta = {
createdAt: number | null;
preview: boolean;
everyMatchOver: boolean;
/** False only while a swiss bracket still has rounds whose matches have not been generated. */
allRoundsHaveMatches: boolean;
participantTournamentTeamIds: number[];
teamsPendingCheckIn: number[] | null;
seeding: number[] | null;
@@ -247,9 +245,6 @@ export class Tournament {
createdAt: bracket.createdAt ?? null,
preview: bracket.preview,
everyMatchOver: bracket.everyMatchOver,
allRoundsHaveMatches: bracket.data.round.every((round) =>
bracket.data.match.some((match) => match.roundId === round.id),
),
participantTournamentTeamIds: bracket.participantTournamentTeamIds,
teamsPendingCheckIn: bracket.teamsPendingCheckIn ?? null,
seeding: bracket.seeding ?? null,
@@ -763,20 +758,7 @@ export class Tournament {
(b) => !b.preview || !b.isUnderground,
);
const everyRoundHasMatches = () => {
// only in swiss matches get generated as tournament progresses
if (
this.ctx.settings.bracketProgression.length > 1 ||
this.ctx.settings.bracketProgression[0].type !== "swiss"
) {
return true;
}
return this.bracketsMeta[0].allRoundsHaveMatches;
};
return (
everyRoundHasMatches() &&
relevantBrackets.every((b) => b.everyMatchOver) &&
this.isOrganizer(user) &&
!this.ctx.isFinalized

View File

@@ -35,6 +35,38 @@ describe("filterWeapon", () => {
).toBe(true);
});
const neoSplash = { type: "MAIN" as const, id: 22 as MainWeaponId };
test("matches a full alt name", () => {
expect(
filterWeapon({
weapon: neoSplash,
weaponName: "Neo Splash-o-matic",
searchTerm: "gecko",
}),
).toBe(true);
});
test("alt names match on the full alt name only, not a partial one", () => {
expect(
filterWeapon({
weapon: neoSplash,
weaponName: "Neo Splash-o-matic",
searchTerm: "geck",
}),
).toBe(false);
});
test("alt names match on the full alt name only, also when the weapon has a single alt name", () => {
expect(
filterWeapon({
weapon: { type: "MAIN", id: 10 as MainWeaponId },
weaponName: "Splattershot Jr.",
searchTerm: "vj",
}),
).toBe(false);
});
test("does not match unrelated weapon", () => {
expect(
filterWeapon({

View File

@@ -32,9 +32,12 @@ export function filterWeapon({
}
if (weapon.type === "MAIN") {
return (
weaponAltNames.get(weapon.id)?.includes(normalizedSearchTerm) ?? false
);
const altNames = weaponAltNames.get(weapon.id);
if (!altNames) return false;
const altNamesList = typeof altNames === "string" ? [altNames] : altNames;
return altNamesList.includes(normalizedSearchTerm);
}
return false;

View File

@@ -43,6 +43,30 @@ describe("queryToUserIdentifier()", () => {
id: 42,
});
});
test("gets custom url from url with trailing slash", () => {
expect(queryToUserIdentifier("https://sendou.ink/u/sendou/")).toEqual({
customUrl: "sendou",
});
});
test("gets custom url from profile sub-page url", () => {
expect(queryToUserIdentifier("https://sendou.ink/u/sendou/builds")).toEqual(
{
customUrl: "sendou",
},
);
});
test("gets discord id from url with query string", () => {
expect(
queryToUserIdentifier(
"https://sendou.ink/u/79237403620945920?utm_source=discord",
),
).toEqual({
discordId: "79237403620945920",
});
});
});
describe("userDiscordIdIsAged()", () => {

View File

@@ -1,8 +1,8 @@
import { logger } from "./logger";
import { isCustomUrl } from "./urls";
const longUrlRegExp = /(https:\/\/)?sendou.ink\/u\/(.+)/;
const shortUrlRegExp = /(https:\/\/)?snd.ink\/(.+)/;
const longUrlRegExp = /(https:\/\/)?sendou\.ink\/u\/([^/?#]+)/;
const shortUrlRegExp = /(https:\/\/)?snd\.ink\/([^/?#]+)/;
const DISCORD_ID_MIN_LENGTH = 17;
export function queryToUserIdentifier(
query: string,