mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Fix more various small bugs
This commit is contained in:
61
app/features/builds/core/ability-sorting.server.test.ts
Normal file
61
app/features/builds/core/ability-sorting.server.test.ts
Normal 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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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()", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user