Various small bug fixes
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-08 22:04:36 +03:00
parent 9c7d15fd88
commit 6a3ec6a654
24 changed files with 400 additions and 27 deletions

View File

@@ -86,6 +86,38 @@ describe("getPageNumbers", () => {
expect(mobileView(19, 20)).toEqual([1, "...", 18, 19, 20]);
});
it("shows a bridging number instead of an ellipsis that hides a single page", () => {
// An ellipsis takes the same space as one page number, so replacing a
// lone hidden page with "..." is never an improvement (same intent as the
// edge "lonely jump" fix, but for windows one step inward).
// desktop window around page 5 of 10 leaves only page 2 hidden on the left
expect(desktopView(5, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, "...", 10]);
// ...and only page 9 hidden on the right for page 6 of 10
expect(desktopView(6, 10)).toEqual([1, "...", 4, 5, 6, 7, 8, 9, 10]);
// mobile window around page 3 of 6 leaves only page 5 hidden
expect(mobileView(3, 6)).toEqual([1, 2, 3, 4, 5, 6]);
});
it("never renders an ellipsis in place of a single hidden page", () => {
for (let pagesCount = 1; pagesCount <= 25; pagesCount++) {
for (let currentPage = 1; currentPage <= pagesCount; currentPage++) {
for (const view of [
mobileView(currentPage, pagesCount),
desktopView(currentPage, pagesCount),
]) {
for (let i = 1; i < view.length - 1; i++) {
if (view[i] !== "...") continue;
const prev = view[i - 1];
const next = view[i + 1];
if (typeof prev === "number" && typeof next === "number") {
expect(next - prev).toBeGreaterThan(2);
}
}
}
}
}
});
it("never produces duplicate page numbers", () => {
for (let pagesCount = 1; pagesCount <= 25; pagesCount++) {
for (let currentPage = 1; currentPage <= pagesCount; currentPage++) {

View File

@@ -253,6 +253,11 @@ export function getPageNumbers(
* last page) to render around the current page. The window is nudged inward by
* one when the current page is the very first or last page, so the edge view
* shows a bridging number instead of a lonely jump like "1 2 … 8".
*
* When exactly one page would be left between the window and the always-shown
* first or last page, the window is widened to include it: an ellipsis takes
* the same space as a single page number, so "1 … 3" is never better than
* "1 2 3".
*/
function innerPageWindow(
currentPage: number,
@@ -262,8 +267,11 @@ function innerPageWindow(
const startNudge = currentPage === pagesCount ? 1 : 0;
const endNudge = currentPage === 1 ? 1 : 0;
return {
start: Math.max(2, currentPage - radius - startNudge),
end: Math.min(pagesCount - 1, currentPage + radius + endNudge),
};
let start = Math.max(2, currentPage - radius - startNudge);
let end = Math.min(pagesCount - 1, currentPage + radius + endNudge);
if (start === 3) start = 2;
if (end === pagesCount - 2) end = pagesCount - 1;
return { start, end };
}

View File

@@ -4,7 +4,7 @@ import { db } from "~/db/sql";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
weekNumberToDate,
weekNumberToDateRange,
} from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import type { GetCalendarWeekResponse } from "../schema";
@@ -35,10 +35,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
};
function fetchEventsOfWeek(args: { week: number; year: number }) {
const startTime = weekNumberToDate(args);
const endTime = new Date(startTime);
endTime.setDate(endTime.getDate() + 7);
const { startTime, endTime } = weekNumberToDateRange(args);
return db
.selectFrom("CalendarEvent")

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { list, nthToDateRange } from "./Seasons";
describe("nthToDateRange()", () => {
it("returns the date range for an existing season", () => {
const { starts, ends } = nthToDateRange(0);
expect(starts).toEqual(list[0].starts);
expect(ends).toEqual(list[0].ends);
});
it("throws for a season number past the end of the list", () => {
expect(() => nthToDateRange(list.length)).toThrow();
});
it("throws for a negative season number", () => {
expect(() => nthToDateRange(-1)).toThrow();
});
});

View File

@@ -167,7 +167,7 @@ export function next(date = new Date()): ListItem | null {
* @throws {Error} If the season does not exist.
*/
export function nthToDateRange(nth: number) {
const seasonObject = list.at(nth);
const seasonObject = list[nth];
if (!seasonObject) {
throw new Error(`Season ${nth} not found`);
}

View File

@@ -359,6 +359,20 @@ describe("applyFilters", () => {
expect(applyFilters(post, filters)).toBe(true);
});
it("returns true when a post time range crossing midnight overlaps the filter", () => {
const post = createPostForFilters(
new Date("2025-01-15T23:00:00"),
new Date("2025-01-16T01:00:00"),
);
const filters: ScrimFilters = {
divs: null,
weekdayTimes: { start: "00:00", end: "02:00" },
weekendTimes: null,
};
expect(applyFilters(post, filters)).toBe(true);
});
});
describe("weekend time filters", () => {

View File

@@ -104,8 +104,19 @@ export function applyFilters(post: ScrimPost, filters: ScrimFilters): boolean {
const startTimeString = format(startDate, "HH:mm");
const endTimeString = format(endDate, "HH:mm");
const hasOverlap =
startTimeString <= timeFilters.end && endTimeString >= timeFilters.start;
// 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 hasOverlap = postSegments.some(
(segment) =>
segment.start <= timeFilters.end && segment.end >= timeFilters.start,
);
if (!hasOverlap) {
return false;

View File

@@ -88,7 +88,7 @@ export const lookingSchema = z.union([
export const weaponUsageSearchParamsSchema = z.object({
userId: id,
season: z.coerce.number().int(),
season: z.coerce.number().int().nonnegative(),
stageId,
modeShort,
});

View File

@@ -640,6 +640,49 @@ describe("validatedSources - other rules", () => {
expect((error as any).bracketIdxs).toEqual([1, 2]);
});
it("only flags GAP_IN_PLACEMENTS brackets sourcing from the problematic bracket", () => {
const error = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1",
},
],
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "3",
},
],
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "1",
placements: "1",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("GAP_IN_PLACEMENTS");
// bracket 3 sources from bracket 1, not from the gap in bracket 0
expect((error as any).bracketIdxs).toEqual([1, 2]);
});
it("handles TOO_MANY_PLACEMENTS", () => {
const error = getValidatedBrackets([
{

View File

@@ -590,7 +590,7 @@ function gapInPlacements(brackets: ParsedBracket[]) {
return brackets.flatMap((bracket, bracketIdx) => {
if (!bracket.sources) return [];
return bracket.sources.flatMap(
return bracket.sources.some(
(source) => source.bracketIdx === problematicBracketIdx,
)
? [bracketIdx]

View File

@@ -519,6 +519,18 @@ describe("Swiss", () => {
2,
]);
});
it("includes thresholds up to the calculated maximum for large round counts", () => {
const roundCount = 9;
const max = Swiss.maxAdvanceThreshold({ roundCount });
expect(Swiss.validAdvanceThresholdOptions({ roundCount })).toContain(
max,
);
expect(
Swiss.isValidAdvanceThreshold({ roundCount, advanceThreshold: max }),
).toBe(true);
});
});
});
});

View File

@@ -525,7 +525,7 @@ export function validAdvanceThresholdOptions({
}) {
const result: number[] = [];
for (let i = 2; i <= Math.min(maxAdvanceThreshold({ roundCount }), 5); i++) {
for (let i = 2; i <= maxAdvanceThreshold({ roundCount }); i++) {
result.push(i);
}

View File

@@ -7,7 +7,11 @@ import {
import { BracketsManager } from "~/modules/brackets-manager";
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
import invariant from "~/utils/invariant";
import { reNumberPlacements, tournamentStandings } from "./Standings";
import {
matchesPlayed,
reNumberPlacements,
tournamentStandings,
} from "./Standings";
describe("tournamentStandings", () => {
it("returns single-division standings for a tournament with one starting bracket", () => {
@@ -137,6 +141,78 @@ describe("reNumberPlacements", () => {
});
});
describe("matchesPlayed", () => {
it("tags each match with the bracket index it was actually played in", () => {
const tournament = roundRobinToSingleEliminationTournament();
const matches = matchesPlayed({ tournament, teamId: 1 });
// team 1 plays 3 round robin matches (bracket idx 0)
// and 1 single elimination match (bracket idx 1)
const roundRobinMatches = matches.filter((m) => m.bracketIdx === 0);
const singleEliminationMatches = matches.filter((m) => m.bracketIdx === 1);
expect(roundRobinMatches).toHaveLength(3);
expect(singleEliminationMatches).toHaveLength(1);
});
});
function roundRobinToSingleEliminationTournament() {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "Main Bracket",
tournamentId: 1,
type: "round_robin",
seeding: [1, 2, 3, 4],
settings: { groupCount: 1, seedOrdering: ["groups.seed_optimized"] },
});
manager.create({
name: "B1",
tournamentId: 1,
type: "single_elimination",
seeding: [1, 2],
settings: { seedOrdering: ["natural"] },
});
// play every match across both brackets, lower id always wins
while (true) {
const pending = storage
.select<any>("match")!
.find(
(m) =>
typeof m.opponent1?.id === "number" &&
typeof m.opponent2?.id === "number" &&
m.opponent1.result !== "win" &&
m.opponent2.result !== "win",
);
if (!pending) break;
const winnerIsOpp1 = pending.opponent1.id < pending.opponent2.id;
manager.update.match({
id: pending.id,
opponent1: winnerIsOpp1 ? { score: 2, result: "win" } : { score: 0 },
opponent2: winnerIsOpp1 ? { score: 0 } : { score: 2, result: "win" },
});
}
return testTournament({
ctx: {
settings: {
bracketProgression: progressions.roundRobinToSingleElimination,
},
teams: [
tournamentCtxTeam(1, { startingBracketIdx: 0, seed: 1 }),
tournamentCtxTeam(2, { startingBracketIdx: 0, seed: 2 }),
tournamentCtxTeam(3, { startingBracketIdx: 0, seed: 3 }),
tournamentCtxTeam(4, { startingBracketIdx: 0, seed: 4 }),
],
},
data: manager.get.tournamentData(1),
});
}
function singleEliminationTournament() {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);

View File

@@ -131,7 +131,7 @@ export function matchesPlayed({
)
.map((match) => ({
...match,
bracketIdx: bracketIdxs[bracketIdxs.length - 1 - i],
bracketIdx: bracketIdxs[i],
})),
);

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { winCounts } from "./sets.server";
describe("winCounts", () => {
it("returns 0% (not NaN) when there are no played sets", () => {
const result = winCounts([]);
expect(result.sets.percentage).toBe(0);
expect(result.maps.percentage).toBe(0);
});
});

View File

@@ -71,12 +71,12 @@ export function winCounts(sets: PlayedSet[]) {
sets: {
won: setsWon,
total: totalSets,
percentage: Math.round((setsWon / totalSets) * 100),
percentage: totalSets === 0 ? 0 : Math.round((setsWon / totalSets) * 100),
},
maps: {
won: mapsWon,
total: totalMaps,
percentage: Math.round((mapsWon / totalMaps) * 100),
percentage: totalMaps === 0 ? 0 : Math.round((mapsWon / totalMaps) * 100),
},
};
}

86
app/utils/dates.test.ts Normal file
View File

@@ -0,0 +1,86 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
getDateAtNextFullHour,
weekNumberToDate,
weekNumberToDateRange,
} from "./dates";
describe("getDateAtNextFullHour", () => {
it("returns a date sitting exactly on a full hour (no leftover minutes/seconds/milliseconds)", () => {
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 30, 0, 500));
expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0));
});
it("advances to the next hour when only seconds/milliseconds are past the hour", () => {
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 30, 0));
expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0));
});
it("keeps the same hour when already exactly on a full hour", () => {
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 0, 0));
expect(result).toEqual(new Date(2024, 0, 1, 14, 0, 0, 0));
});
});
describe("weekNumberToDate", () => {
// Force a timezone west of UTC so the assertion is deterministic regardless
// of where the test happens to run (the bug only manifests west of UTC).
const originalTimezone = process.env.TZ;
beforeAll(() => {
process.env.TZ = "America/Los_Angeles";
});
afterAll(() => {
process.env.TZ = originalTimezone;
});
it("returns the Monday of the ISO week regardless of server timezone", () => {
// ISO week 1 of 2024 starts on Monday 2024-01-01
const start = weekNumberToDate({ week: 1, year: 2024 });
expect(start.toISOString().slice(0, 10)).toBe("2024-01-01");
});
it("returns the Sunday of the ISO week regardless of server timezone", () => {
// ISO week 1 of 2024 ends on Sunday 2024-01-07
const end = weekNumberToDate({ week: 1, year: 2024, position: "end" });
expect(end.toISOString().slice(0, 10)).toBe("2024-01-07");
});
});
describe("weekNumberToDateRange", () => {
// Force a timezone west of UTC observing DST so the assertion is deterministic
// regardless of where the test happens to run.
const originalTimezone = process.env.TZ;
beforeAll(() => {
process.env.TZ = "America/New_York";
});
afterAll(() => {
process.env.TZ = originalTimezone;
});
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
it("spans exactly seven days even when the week contains a DST transition", () => {
// US spring-forward 2025 happened on Sunday 2025-03-09, which falls inside
// ISO week 10 of 2025 (Mon 2025-03-03 .. Mon 2025-03-10).
const { startTime, endTime } = weekNumberToDateRange({
week: 10,
year: 2025,
});
expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS);
});
it("spans exactly seven days for an ordinary week", () => {
const { startTime, endTime } = weekNumberToDateRange({
week: 20,
year: 2025,
});
expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS);
});
});

View File

@@ -133,15 +133,36 @@ export function weekNumberToDate({
}) {
const result = new Date(Date.UTC(year, 0, 4));
result.setDate(
result.getDate() - (result.getDay() || 7) + 1 + 7 * (week - 1),
result.setUTCDate(
result.getUTCDate() - (result.getUTCDay() || 7) + 1 + 7 * (week - 1),
);
if (position === "end") {
result.setDate(result.getDate() + 6);
result.setUTCDate(result.getUTCDate() + 6);
}
return result;
}
/**
* Returns the UTC date range covering an ISO week: the Monday that starts the
* week and the Monday that starts the following week (a 7-day span). Uses UTC
* date arithmetic so the span is exactly 7×24h regardless of the server's
* timezone or any DST transition that falls inside the week.
*/
export function weekNumberToDateRange({
week,
year,
}: {
week: number;
year: number;
}) {
const startTime = weekNumberToDate({ week, year });
const endTime = new Date(startTime);
endTime.setUTCDate(endTime.getUTCDate() + 7);
return { startTime, endTime };
}
/**
* Checks if a date is valid or not.
*
@@ -190,11 +211,16 @@ export function getDateWithHoursOffset(date: Date, hoursOffset: number) {
export function getDateAtNextFullHour(date: Date) {
const copiedDate = new Date(date.getTime());
if (date.getMinutes() > 0) {
if (
date.getMinutes() > 0 ||
date.getSeconds() > 0 ||
date.getMilliseconds() > 0
) {
copiedDate.setHours(date.getHours() + 1);
copiedDate.setMinutes(0);
}
copiedDate.setSeconds(0);
copiedDate.setMilliseconds(0);
return copiedDate;
}

View File

@@ -27,7 +27,9 @@ export function roundToNDecimalPlaces(num: number, n = 2) {
*/
export function cutToNDecimalPlaces(num: number, n = 2) {
const multiplier = 10 ** n;
const truncatedNum = Math.trunc(num * multiplier) / multiplier;
// Round away floating point representation error (e.g. 0.29 * 100 = 28.999...) before truncating
const scaled = Number((num * multiplier).toFixed(8));
const truncatedNum = Math.trunc(scaled) / multiplier;
const result = truncatedNum.toFixed(n);
return Number(n > 0 ? result.replace(/\.?0+$/, "") : result);
}

View File

@@ -61,6 +61,16 @@ describe("cutToNDecimalPlaces()", () => {
const result = cutToNDecimalPlaces(3.0001, 2);
expect(result).toBe(3);
});
test("cutOff preserves a value already at the desired number of decimal places", () => {
const result = cutToNDecimalPlaces(0.29, 2);
expect(result).toBe(0.29);
});
test("cutOff is not thrown off by floating point representation error", () => {
expect(cutToNDecimalPlaces(2.32, 2)).toBe(2.32);
expect(cutToNDecimalPlaces(-0.29, 2)).toBe(-0.29);
});
});
describe("averageArray()", () => {

View File

@@ -15,6 +15,12 @@ describe("pathnameFromPotentialURL()", () => {
test("Returns string as is if not URL", () => {
expect(pathnameFromPotentialURL("sendouc")).toBe("sendouc");
});
test("Strips trailing slash from URL path", () => {
expect(pathnameFromPotentialURL("https://discord.gg/FW4dKrY/")).toBe(
"FW4dKrY",
);
});
});
describe("truncateBySentence()", () => {

View File

@@ -51,7 +51,7 @@ export function gearTypeToInitial(gearType: GearType) {
export function pathnameFromPotentialURL(maybeUrl: string) {
try {
return new URL(maybeUrl).pathname.replace("/", "");
return new URL(maybeUrl).pathname.replace(/^\/+|\/+$/g, "");
} catch {
return maybeUrl;
}

12
app/utils/urls.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { userArtPage } from "./urls";
describe("userArtPage()", () => {
it("joins source and bigArtId params with a single query string", () => {
const url = userArtPage({ discordId: "123" }, "MADE-BY", 456);
const params = new URLSearchParams(url.split("?")[1]);
expect(params.get("source")).toBe("MADE-BY");
expect(params.get("big")).toBe("456");
});
});

View File

@@ -181,8 +181,17 @@ export const userArtPage = (
user: UserLinkArgs,
source?: ArtSource,
bigArtId?: number,
) =>
`${userPage(user)}/art${source ? `?source=${source}` : ""}${bigArtId ? `?big=${bigArtId}` : ""}`;
) => {
const params = new URLSearchParams();
if (source) {
params.set("source", source);
}
if (typeof bigArtId === "number") {
params.set("big", String(bigArtId));
}
return `${userPage(user)}/art${params.size > 0 ? `?${params.toString()}` : ""}`;
};
export const newArtPage = (artId?: Tables["Art"]["id"]) =>
`${artPage()}/new${artId ? `?art=${artId}` : ""}`;
export const userNewBuildPage = (