From e06f7177dd56362f2f7d9f2c9ac299fd6bb8c81f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:52:20 +0300 Subject: [PATCH] Fix more various small bugs --- .../core/ability-sorting.server.test.ts | 61 +++++++++++++++++++ .../builds/core/ability-sorting.server.ts | 9 ++- app/features/mmr/core/Seasons.ts | 2 +- app/features/scrims/core/Scrim.test.ts | 11 ++++ app/features/scrims/core/Scrim.ts | 32 ++++++---- .../tier-list-maker-schemas.ts | 4 +- .../core/Progression.test.ts | 29 +++++++++ .../tournament-bracket/core/Progression.ts | 1 + app/features/vods/vods-utils.test.ts | 36 ++++++++++- app/features/vods/vods-utils.ts | 6 +- app/utils/kysely.server.ts | 3 +- app/utils/string.test.ts | 14 +++++ app/utils/strings.ts | 4 +- app/utils/urls.test.ts | 29 ++++++++- app/utils/urls.ts | 12 +++- app/utils/users.test.ts | 6 ++ app/utils/users.ts | 6 +- app/utils/zod.test.ts | 30 +++++++++ app/utils/zod.ts | 7 ++- 19 files changed, 270 insertions(+), 32 deletions(-) create mode 100644 app/features/builds/core/ability-sorting.server.test.ts diff --git a/app/features/builds/core/ability-sorting.server.test.ts b/app/features/builds/core/ability-sorting.server.test.ts new file mode 100644 index 000000000..2171a843e --- /dev/null +++ b/app/features/builds/core/ability-sorting.server.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import type { BuildAbilitiesTuple } from "~/modules/in-game-lists/types"; +import { sortAbilities } from "./ability-sorting.server"; + +describe("sortAbilities()", () => { + test("reorders stackable main abilities into canonical order", () => { + const input: BuildAbilitiesTuple = [ + ["ISM", "SS", "SS", "SS"], + ["SSU", "SS", "SS", "SS"], + ["QR", "SS", "SS", "SS"], + ]; + + expect(sortAbilities(input)).toEqual([ + ["QR", "SS", "SS", "SS"], + ["SSU", "SS", "SS", "SS"], + ["ISM", "SS", "SS", "SS"], + ]); + }); + + test("keeps main-only abilities in their row while sorting stackable mains", () => { + const input: BuildAbilitiesTuple = [ + ["LDE", "QR", "QR", "QR"], + ["SSU", "QR", "QR", "QR"], + ["ISM", "QR", "QR", "QR"], + ]; + + expect(sortAbilities(input)).toEqual([ + ["LDE", "QR", "QR", "QR"], + ["SSU", "QR", "QR", "QR"], + ["ISM", "QR", "QR", "QR"], + ]); + }); + + test("groups scattered sub abilities by frequency", () => { + const input: BuildAbilitiesTuple = [ + ["OG", "SPU", "ISS", "QR"], + ["NS", "SPU", "ISS", "SPU"], + ["SJ", "SPU", "ISS", "SPU"], + ]; + + expect(sortAbilities(input)).toEqual([ + ["OG", "SPU", "SPU", "SPU"], + ["NS", "ISS", "ISS", "ISS"], + ["SJ", "SPU", "SPU", "QR"], + ]); + }); + + test("aligns sub rows with mains when two rows want each other's subs", () => { + const input: BuildAbilitiesTuple = [ + ["SSU", "ISM", "ISM", "ISM"], + ["ISM", "SSU", "SSU", "SSU"], + ["SJ", "QSJ", "QSJ", "QSJ"], + ]; + + expect(sortAbilities(input)).toEqual([ + ["SSU", "SSU", "SSU", "SSU"], + ["ISM", "ISM", "ISM", "ISM"], + ["SJ", "QSJ", "QSJ", "QSJ"], + ]); + }); +}); diff --git a/app/features/builds/core/ability-sorting.server.ts b/app/features/builds/core/ability-sorting.server.ts index 0b0093c35..156947a70 100644 --- a/app/features/builds/core/ability-sorting.server.ts +++ b/app/features/builds/core/ability-sorting.server.ts @@ -119,15 +119,18 @@ function switchSubRowsIfBetter( abilities: BuildAbilitiesTuple, ): BuildAbilitiesTuple { const desiredMoves: [source: number, target: number][] = []; + const rowsInvolvedInMove = new Set(); for (const [i, row] of abilities.entries()) { + if (rowsInvolvedInMove.has(i)) continue; + const [m, s1] = row; // already in a good place if (m === s1) continue; for (const [j, row2] of abilities.entries()) { - if (i === j) continue; + if (i === j || rowsInvolvedInMove.has(j)) continue; const [m2, s21] = row2; @@ -136,8 +139,10 @@ function switchSubRowsIfBetter( continue; } - if (m2 === s1 && !desiredMoves.some(([, target]) => target === j)) { + if (m2 === s1) { desiredMoves.push([i, j]); + rowsInvolvedInMove.add(i); + rowsInvolvedInMove.add(j); break; } } diff --git a/app/features/mmr/core/Seasons.ts b/app/features/mmr/core/Seasons.ts index e107b3079..c0b6e7cac 100644 --- a/app/features/mmr/core/Seasons.ts +++ b/app/features/mmr/core/Seasons.ts @@ -181,7 +181,7 @@ export function nthToDateRange(nth: number) { /** * Retrieves a list of season numbers that have started based on the provided date (defaults to now). * - * @returns An array of season numbers in asceding order. If no seasons have started, returns an array containing only `[0]`. + * @returns An array of season numbers in descending order (newest first). If no seasons have started, returns an array containing only `[0]`. */ export function allStarted(date = new Date()) { const startedSeasons = list.filter((s) => date >= s.starts); diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index cd2ff42b3..53554ee83 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -373,6 +373,17 @@ describe("applyFilters", () => { expect(applyFilters(post, filters)).toBe(true); }); + + it("returns true when a filter crossing midnight covers the post time", () => { + const post = createPostForFilters(new Date("2025-01-15T21:00:00")); + const filters: ScrimFilters = { + divs: null, + weekdayTimes: { start: "20:00", end: "02:00" }, + weekendTimes: null, + }; + + expect(applyFilters(post, filters)).toBe(true); + }); }); describe("weekend time filters", () => { diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index 1ae871bff..0235c508e 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -104,18 +104,18 @@ export function applyFilters(post: ScrimPost, filters: ScrimFilters): boolean { const startTimeString = format(startDate, "HH:mm"); const endTimeString = format(endDate, "HH:mm"); - // a range that crosses midnight (e.g. 23:00 -> 01:00) is two segments - const postSegments = - endTimeString < startTimeString - ? [ - { start: startTimeString, end: "24:00" }, - { start: "00:00", end: endTimeString }, - ] - : [{ start: startTimeString, end: endTimeString }]; + const postSegments = timeRangeToSegments(startTimeString, endTimeString); + const filterSegments = timeRangeToSegments( + timeFilters.start, + timeFilters.end, + ); - const hasOverlap = postSegments.some( - (segment) => - segment.start <= timeFilters.end && segment.end >= timeFilters.start, + const hasOverlap = postSegments.some((postSegment) => + filterSegments.some( + (filterSegment) => + postSegment.start <= filterSegment.end && + postSegment.end >= filterSegment.start, + ), ); if (!hasOverlap) { @@ -203,3 +203,13 @@ export function lastReportedMap< [(m) => m.index, "desc"], ); } + +/** Splits a "HH:mm" time range into segments, breaking a range that crosses midnight (e.g. 23:00 -> 01:00) into two. */ +function timeRangeToSegments(start: string, end: string) { + return end < start + ? [ + { start, end: "24:00" }, + { start: "00:00", end }, + ] + : [{ start, end }]; +} diff --git a/app/features/tier-list-maker/tier-list-maker-schemas.ts b/app/features/tier-list-maker/tier-list-maker-schemas.ts index 19f89e011..ee598faa6 100644 --- a/app/features/tier-list-maker/tier-list-maker-schemas.ts +++ b/app/features/tier-list-maker/tier-list-maker-schemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { assertType } from "~/utils/types"; import { - hexCode, + hexCodeWithoutAlpha, modeShort, specialWeaponId, stageId, @@ -57,7 +57,7 @@ export type TierListItem = z.infer; const tierSchema = z.object({ id: z.string(), name: z.string(), - color: hexCode, + color: hexCodeWithoutAlpha, }); export type TierListMakerTier = z.infer; diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 3a96af5ca..608c2ad61 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -230,6 +230,35 @@ describe("validatedSources - PLACEMENTS_PARSE_ERROR", () => { expect(error.type).toBe("PLACEMENTS_PARSE_ERROR"); }); + it("parsing fails with a reversed placement range", () => { + const error = Progression.validatedBrackets([ + { + id: "1", + name: "Swiss Bracket", + type: "swiss", + settings: { + advanceThreshold: 3, + }, + requiresCheckIn: false, + }, + { + id: "2", + name: "Final Bracket", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + sources: [ + { + bracketId: "1", + placements: "3-1", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("PLACEMENTS_PARSE_ERROR"); + }); + it("parsing fails with empty string placements for Swiss brackets without early advance", () => { const error = Progression.validatedBrackets([ { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 1141e685c..db571e288 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -437,6 +437,7 @@ function parsePlacements( if (part.includes("-")) { const [start, end] = part.split("-").map(Number); + if (end < start) return null; for (let n = start; n <= end; n++) { result.push(n); diff --git a/app/features/vods/vods-utils.test.ts b/app/features/vods/vods-utils.test.ts index c7fb043c0..331f47e0a 100644 --- a/app/features/vods/vods-utils.test.ts +++ b/app/features/vods/vods-utils.test.ts @@ -1,14 +1,17 @@ -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { MainWeaponId, ModeShort, StageId, } from "~/modules/in-game-lists/types"; +import { dayMonthYearToDatabaseTimestamp } from "~/utils/dates"; +import type { Vod } from "./vods-types"; import { extractYoutubeIdFromVideoUrl, generateYoutubeTimestamps, hoursMinutesSecondsStringToSeconds, secondsToHoursMinutesSecondString, + vodToVideoBeingAdded, } from "./vods-utils"; describe("extractYoutubeIdFromVideoUrl", () => { @@ -195,6 +198,37 @@ describe("generateYoutubeTimestamps", () => { }); }); +describe("vodToVideoBeingAdded", () => { + // youtubeDate is stored as noon UTC of the chosen day (see + // dayMonthYearToDatabaseTimestamp), so reading the day/month/year back out + // must also use UTC. Force a timezone east of UTC+12 where local time has + // already rolled over to the next day, making the bug deterministic. + const originalTimezone = process.env.TZ; + beforeAll(() => { + process.env.TZ = "Pacific/Kiritimati"; + }); + afterAll(() => { + process.env.TZ = originalTimezone; + }); + + it("round-trips the stored day/month/year regardless of server timezone", () => { + const date = { day: 5, month: 0, year: 2024 }; + const vod: Vod = { + id: 1, + title: "Test VOD", + type: "TOURNAMENT", + youtubeId: "dQw4w9WgXcQ", + youtubeDate: dayMonthYearToDatabaseTimestamp(date), + submitterUserId: 1, + matches: [], + }; + + const result = vodToVideoBeingAdded(vod); + + expect(result.date).toEqual(date); + }); +}); + describe("hoursMinutesSecondsStringToSeconds", () => { it("should convert HH:MM:SS format to seconds", () => { const result = hoursMinutesSecondsStringToSeconds("1:01:01"); diff --git a/app/features/vods/vods-utils.ts b/app/features/vods/vods-utils.ts index 7cc99f6b9..89d703cff 100644 --- a/app/features/vods/vods-utils.ts +++ b/app/features/vods/vods-utils.ts @@ -11,9 +11,9 @@ export function vodToVideoBeingAdded(vod: Vod): VideoBeingAdded { title: vod.title, youtubeUrl: youtubeIdToYoutubeUrl(vod.youtubeId), date: { - day: dateObj.getDate(), - month: dateObj.getMonth(), - year: dateObj.getFullYear(), + day: dateObj.getUTCDate(), + month: dateObj.getUTCMonth(), + year: dateObj.getUTCFullYear(), }, matches: vod.matches.map((match) => ({ ...match, diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index 40b0b4383..2d2c0908e 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -94,8 +94,7 @@ export function tournamentLogoWithDefault( "UnvalidatedUserSubmittedImage.id", ) .$asScalar(), - sql.lit(`${import.meta.env.VITE_TOURNAMENT_DEFAULT_LOGO} - `), + sql.lit(`${import.meta.env.VITE_TOURNAMENT_DEFAULT_LOGO}`), ), ); } diff --git a/app/utils/string.test.ts b/app/utils/string.test.ts index bdb48ac41..3ef5f9ee4 100644 --- a/app/utils/string.test.ts +++ b/app/utils/string.test.ts @@ -86,4 +86,18 @@ describe("removeMarkdown()", () => { removeMarkdown("Check out [the site](https://example.com) today"), ).toBe("Check out the site today"); }); + + test("Keeps non-header # characters", () => { + expect(removeMarkdown("Showdown #1 starts now")).toBe( + "Showdown #1 starts now", + ); + }); + + test("Leaves space-flanked asterisks intact instead of mangling them", () => { + expect(removeMarkdown("** bold text **")).toBe("** bold text **"); + }); + + test("Strips emphasis with inner spaces", () => { + expect(removeMarkdown("*a b c*")).toBe("a b c"); + }); }); diff --git a/app/utils/strings.ts b/app/utils/strings.ts index 8f2392e4f..1d511fb53 100644 --- a/app/utils/strings.ts +++ b/app/utils/strings.ts @@ -119,9 +119,9 @@ export function removeMarkdown(value: string) { // Remove reference-style links? .replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "") // Remove headers - .replaceAll("#", "") + .replace(/^\s{0,3}#{1,6}\s*/gm, "") // Remove * emphasis - .replace(/([*]+)(\S)(.*?\S)??\1/g, "$2$3") + .replace(/(\*+)([^\s*])(.*?[^\s*])??\1/g, "$2$3") // Remove _ emphasis. Unlike *, _ emphasis gets rendered only if // 1. Either there is a whitespace character before opening _ and after closing _. // 2. Or _ is at the start/end of the string. diff --git a/app/utils/urls.test.ts b/app/utils/urls.test.ts index d4e7e409f..e478711a6 100644 --- a/app/utils/urls.test.ts +++ b/app/utils/urls.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from "vitest"; -import { userArtPage } from "./urls"; +import { + leaderboardsPage, + tournamentOrganizationPage, + userArtPage, +} from "./urls"; + +describe("leaderboardsPage()", () => { + it("encodes season 0 in the query string", () => { + const url = leaderboardsPage({ season: 0, type: "USER" }); + + const params = new URLSearchParams(url.split("?")[1]); + expect(params.get("season")).toBe("0"); + }); +}); describe("userArtPage()", () => { it("joins source and bigArtId params with a single query string", () => { @@ -10,3 +23,17 @@ describe("userArtPage()", () => { expect(params.get("big")).toBe("456"); }); }); + +describe("tournamentOrganizationPage()", () => { + it("round-trips the tournament name through the source param", () => { + const tournamentName = "100% Series"; + + const url = tournamentOrganizationPage({ + organizationSlug: "sendou", + tournamentName, + }); + + const params = new URLSearchParams(url.split("?")[1]); + expect(params.get("source")).toBe(tournamentName); + }); +}); diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 73b195d4e..3ebcfed3f 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -239,7 +239,7 @@ export const leaderboardsPage = (args: { type?: "USER" | "TEAM"; }) => { const params = new URLSearchParams(); - if (args.season) { + if (typeof args.season === "number") { params.set("season", String(args.season)); } if (args.type) { @@ -378,8 +378,14 @@ export const tournamentOrganizationPage = ({ }: { organizationSlug: string; tournamentName?: string; -}) => - `/org/${organizationSlug}${tournamentName ? `?source=${decodeURIComponent(tournamentName)}` : ""}`; +}) => { + const params = new URLSearchParams(); + if (tournamentName) { + params.set("source", tournamentName); + } + + return `/org/${organizationSlug}${params.size > 0 ? `?${params.toString()}` : ""}`; +}; export const tournamentOrganizationEditPage = (organizationSlug: string) => `${tournamentOrganizationPage({ organizationSlug })}/edit`; diff --git a/app/utils/users.test.ts b/app/utils/users.test.ts index b03c46374..d46a6b7c1 100644 --- a/app/utils/users.test.ts +++ b/app/utils/users.test.ts @@ -37,6 +37,12 @@ describe("queryToUserIdentifier()", () => { id: 1, }); }); + + test("gets id from url", () => { + expect(queryToUserIdentifier("https://sendou.ink/u/42")).toEqual({ + id: 42, + }); + }); }); describe("userDiscordIdIsAged()", () => { diff --git a/app/utils/users.ts b/app/utils/users.ts index 6cf14d931..c7fa6ce38 100644 --- a/app/utils/users.ts +++ b/app/utils/users.ts @@ -17,7 +17,11 @@ export function queryToUserIdentifier( return { customUrl: identifier }; } - return { discordId: identifier }; + if (identifier.length >= DISCORD_ID_MIN_LENGTH) { + return { discordId: identifier }; + } + + return { id: Number(identifier) }; } // = it's numeric diff --git a/app/utils/zod.test.ts b/app/utils/zod.test.ts index cd5cbe844..c24b2e55a 100644 --- a/app/utils/zod.test.ts +++ b/app/utils/zod.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { actuallyNonEmptyStringOrNull, hasZalgo, + hexCodeWithoutAlpha, normalizeFriendCode, timeString, } from "./zod"; @@ -44,6 +45,13 @@ describe("hasZalgo", () => { it("accepts japanese characters", () => { expect(hasZalgo("こんにちは")).toBe(false); }); + + it("returns a stable result when called repeatedly with the same input", () => { + const withCombiningMark = "á"; // "á" as base letter + single combining accent + + expect(hasZalgo(withCombiningMark)).toBe(true); + expect(hasZalgo(withCombiningMark)).toBe(true); + }); }); describe("actuallyNonEmptyStringOrNull", () => { @@ -91,6 +99,28 @@ describe("actuallyNonEmptyStringOrNull", () => { }); }); +describe("hexCodeWithoutAlpha", () => { + it("accepts valid 3 and 6 digit hex colors", () => { + expect(hexCodeWithoutAlpha.safeParse("#fff").success).toBe(true); + expect(hexCodeWithoutAlpha.safeParse("#FFF").success).toBe(true); + expect(hexCodeWithoutAlpha.safeParse("#abc").success).toBe(true); + expect(hexCodeWithoutAlpha.safeParse("#ffffff").success).toBe(true); + expect(hexCodeWithoutAlpha.safeParse("#a1b2c3").success).toBe(true); + }); + + it("rejects strings that are not valid hex colors", () => { + expect(hexCodeWithoutAlpha.safeParse("#fff99").success).toBe(false); + expect(hexCodeWithoutAlpha.safeParse("#abc12").success).toBe(false); + expect(hexCodeWithoutAlpha.safeParse("#12345").success).toBe(false); + expect(hexCodeWithoutAlpha.safeParse("#ffffff99").success).toBe(false); + }); + + it("rejects alpha (4 and 8 digit) hex colors", () => { + expect(hexCodeWithoutAlpha.safeParse("#ffff").success).toBe(false); + expect(hexCodeWithoutAlpha.safeParse("#ffffffff").success).toBe(false); + }); +}); + describe("timeString", () => { it("accepts valid time in HH:MM format", () => { expect(timeString.safeParse("00:00").success).toBe(true); diff --git a/app/utils/zod.ts b/app/utils/zod.ts index 5497527b3..4a255835e 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -32,8 +32,9 @@ export const nonEmptyString = z.string().trim().min(1, { export const dbBoolean = z.coerce.number().min(0).max(1).int(); -const hexCodeRegex = /^#(?:[0-9a-fA-F]{3}){1,2}[0-9]{0,2}$/; // https://stackoverflow.com/a/1636354 -export const hexCode = z.string().regex(hexCodeRegex); +// matches #RGB and #RRGGBB only (no alpha) https://stackoverflow.com/a/1636354 +const hexCodeWithoutAlphaRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/; +export const hexCodeWithoutAlpha = z.string().regex(hexCodeWithoutAlphaRegex); export const THEME_INPUT_LIMITS = { BASE_HUE_MIN: 0, @@ -295,7 +296,7 @@ const EMPTY_CHARACTERS = [ ]; const EMPTY_CHARACTERS_REGEX = new RegExp(EMPTY_CHARACTERS.join("|"), "g"); -const zalgoRe = /%CC%/g; +const zalgoRe = /%CC%/; export const hasZalgo = (txt: string) => zalgoRe.test(encodeURIComponent(txt)); /** Non-empty string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */