Fix more various small bugs
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle
2026-06-09 21:52:20 +03:00
parent 734c6128d3
commit e06f7177dd
19 changed files with 270 additions and 32 deletions

View File

@@ -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"],
]);
});
});

View File

@@ -119,15 +119,18 @@ function switchSubRowsIfBetter(
abilities: BuildAbilitiesTuple,
): BuildAbilitiesTuple {
const desiredMoves: [source: number, target: number][] = [];
const rowsInvolvedInMove = new Set<number>();
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;
}
}

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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 }];
}

View File

@@ -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<typeof tierListItemSchema>;
const tierSchema = z.object({
id: z.string(),
name: z.string(),
color: hexCode,
color: hexCodeWithoutAlpha,
});
export type TierListMakerTier = z.infer<typeof tierSchema>;

View File

@@ -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([
{

View File

@@ -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);

View File

@@ -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");

View File

@@ -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,

View File

@@ -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}`),
),
);
}

View File

@@ -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");
});
});

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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`;

View File

@@ -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()", () => {

View File

@@ -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

View File

@@ -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);

View File

@@ -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. */