diff --git a/app/components/RelativeTime.browser.test.tsx b/app/components/RelativeTime.browser.test.tsx new file mode 100644 index 000000000..80d1eae35 --- /dev/null +++ b/app/components/RelativeTime.browser.test.tsx @@ -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: 3 days ago, + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +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)); + }); +}); diff --git a/app/components/RelativeTime.tsx b/app/components/RelativeTime.tsx index f37ab7ff0..e825fde61 100644 --- a/app/components/RelativeTime.tsx +++ b/app/components/RelativeTime.tsx @@ -17,6 +17,8 @@ export function RelativeTime({ }); return ( - {children} + + {children} + ); } diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 42fbefe56..470b416e1 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -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!; diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index 4892646b0..dc77ec149 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -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( diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 083e76e92..7a2555da4 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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 diff --git a/app/modules/in-game-lists/utils.test.ts b/app/modules/in-game-lists/utils.test.ts index aa63d6c92..c982751c3 100644 --- a/app/modules/in-game-lists/utils.test.ts +++ b/app/modules/in-game-lists/utils.test.ts @@ -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({ diff --git a/app/modules/in-game-lists/utils.ts b/app/modules/in-game-lists/utils.ts index 1f60aab1e..af466d098 100644 --- a/app/modules/in-game-lists/utils.ts +++ b/app/modules/in-game-lists/utils.ts @@ -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; diff --git a/app/utils/users.test.ts b/app/utils/users.test.ts index d46a6b7c1..4763bca8c 100644 --- a/app/utils/users.test.ts +++ b/app/utils/users.test.ts @@ -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()", () => { diff --git a/app/utils/users.ts b/app/utils/users.ts index c7fa6ce38..373f2a90a 100644 --- a/app/utils/users.ts +++ b/app/utils/users.ts @@ -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,