mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-13 21:59:58 -05:00
Various small bug fixes
This commit is contained in:
@@ -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++) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
18
app/features/mmr/core/Seasons.test.ts
Normal file
18
app/features/mmr/core/Seasons.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -131,7 +131,7 @@ export function matchesPlayed({
|
||||
)
|
||||
.map((match) => ({
|
||||
...match,
|
||||
bracketIdx: bracketIdxs[bracketIdxs.length - 1 - i],
|
||||
bracketIdx: bracketIdxs[i],
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
11
app/features/tournament/core/sets.server.test.ts
Normal file
11
app/features/tournament/core/sets.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
86
app/utils/dates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()", () => {
|
||||
|
||||
@@ -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()", () => {
|
||||
|
||||
@@ -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
12
app/utils/urls.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user