chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
This commit is contained in:
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
*/
|
||||
export function createDebouncedFunction<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delayMs: number = 300
|
||||
delayMs: number = 300,
|
||||
) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss"
|
||||
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
|
||||
* @returns Formatted duration string
|
||||
*/
|
||||
export function formatSecondsDuration(seconds: number, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
|
||||
export function formatSecondsDuration(
|
||||
seconds: number,
|
||||
format: "mm:ss" | "hh:mm:ss" = "mm:ss",
|
||||
): string {
|
||||
if (format === "hh:mm:ss") {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -43,13 +43,9 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites
|
||||
*
|
||||
* TRACES: UR-075 | DR-175
|
||||
*/
|
||||
export function asFavoritesScope(
|
||||
scope: SearchScope | null | undefined,
|
||||
): FavoritesScope | null {
|
||||
export function asFavoritesScope(scope: SearchScope | null | undefined): FavoritesScope | null {
|
||||
if (!scope) return null;
|
||||
return (FAVORITE_SCOPES as readonly string[]).includes(scope)
|
||||
? (scope as FavoritesScope)
|
||||
: null;
|
||||
return (FAVORITE_SCOPES as readonly string[]).includes(scope) ? (scope as FavoritesScope) : null;
|
||||
}
|
||||
|
||||
/** URL for a tab. The default scope is omitted, keeping the base URL clean. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { selectDiverseGenres, sampleAcross } from "./genreDiversity";
|
||||
|
||||
const g = (...names: string[]) => names.map(name => ({ name }));
|
||||
const g = (...names: string[]) => names.map((name) => ({ name }));
|
||||
|
||||
describe("sampleAcross", () => {
|
||||
it("returns input unchanged when at or under the count", () => {
|
||||
@@ -32,7 +32,7 @@ describe("selectDiverseGenres", () => {
|
||||
|
||||
it("seeds with the most populous genre (first in input)", () => {
|
||||
const out = selectDiverseGenres(g("Rock", "Jazz", "Hip Hop"), 1);
|
||||
expect(out.map(x => x.name)).toEqual(["Rock"]);
|
||||
expect(out.map((x) => x.name)).toEqual(["Rock"]);
|
||||
});
|
||||
|
||||
it("spreads the family instead of stacking near-synonyms", () => {
|
||||
@@ -45,35 +45,28 @@ describe("selectDiverseGenres", () => {
|
||||
"Pop Rock",
|
||||
"Jazz",
|
||||
"Hip Hop",
|
||||
"Classical"
|
||||
"Classical",
|
||||
);
|
||||
const names = selectDiverseGenres(input, 4).map(x => x.name);
|
||||
const names = selectDiverseGenres(input, 4).map((x) => x.name);
|
||||
|
||||
expect(names[0]).toBe("Rock"); // seeded by count
|
||||
expect(names).toContain("Jazz");
|
||||
expect(names).toContain("Hip Hop");
|
||||
expect(names).toContain("Classical");
|
||||
// Only the seed represents the Rock cluster.
|
||||
expect(names.filter(n => n.includes("Rock"))).toEqual(["Rock"]);
|
||||
expect(names.filter((n) => n.includes("Rock"))).toEqual(["Rock"]);
|
||||
});
|
||||
|
||||
it("preserves count order as the tie-breaker among equally-distinct genres", () => {
|
||||
// All four are mutually distinct (no shared tokens), so every pick after
|
||||
// the seed is a distance tie and should follow input (count) order.
|
||||
const input = g("Rock", "Jazz", "Blues", "Folk");
|
||||
expect(selectDiverseGenres(input, 3).map(x => x.name)).toEqual([
|
||||
"Rock",
|
||||
"Jazz",
|
||||
"Blues",
|
||||
]);
|
||||
expect(selectDiverseGenres(input, 3).map((x) => x.name)).toEqual(["Rock", "Jazz", "Blues"]);
|
||||
});
|
||||
|
||||
it("is case- and separator-insensitive when comparing", () => {
|
||||
const input = g("Hip Hop", "hip-hop", "Reggae");
|
||||
// "Hip Hop" and "hip-hop" share all tokens → the second is redundant.
|
||||
expect(selectDiverseGenres(input, 2).map(x => x.name)).toEqual([
|
||||
"Hip Hop",
|
||||
"Reggae",
|
||||
]);
|
||||
expect(selectDiverseGenres(input, 2).map((x) => x.name)).toEqual(["Hip Hop", "Reggae"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ function tokenize(name: string): Set<string> {
|
||||
name
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
*/
|
||||
export function selectDiverseGenres<T extends DiversityCandidate>(
|
||||
candidates: T[],
|
||||
limit: number
|
||||
limit: number,
|
||||
): T[] {
|
||||
if (candidates.length <= limit) return candidates.slice();
|
||||
|
||||
const tokens = candidates.map(c => tokenize(c.name));
|
||||
const tokens = candidates.map((c) => tokenize(c.name));
|
||||
const chosen: number[] = [];
|
||||
const remaining = new Set(candidates.map((_, i) => i));
|
||||
|
||||
@@ -91,5 +91,5 @@ export function selectDiverseGenres<T extends DiversityCandidate>(
|
||||
remaining.delete(best);
|
||||
}
|
||||
|
||||
return chosen.map(i => candidates[i]);
|
||||
return chosen.map((i) => candidates[i]);
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ describe("buildHeroMix", () => {
|
||||
|
||||
it("filters items without artwork", () => {
|
||||
const result = buildHeroMix([[item("a", false), item("b")]], hasArt);
|
||||
expect(result.map(i => i.id)).toEqual(["b"]);
|
||||
expect(result.map((i) => i.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("de-duplicates across pools", () => {
|
||||
const a = item("a");
|
||||
const result = buildHeroMix([[a], [a, item("b")]], hasArt);
|
||||
const ids = result.map(i => i.id);
|
||||
const ids = result.map((i) => i.id);
|
||||
expect(ids).toHaveLength(new Set(ids).size);
|
||||
expect(ids).toContain("a");
|
||||
expect(ids).toContain("b");
|
||||
@@ -63,8 +63,8 @@ describe("buildHeroMix", () => {
|
||||
// count 4 with perPool 2: exactly 2 from each pool, no backfill needed.
|
||||
for (let run = 0; run < 20; run++) {
|
||||
const result = buildHeroMix([a, b], hasArt, 4, 2);
|
||||
const fromA = result.filter(i => i.id.startsWith("a")).length;
|
||||
const fromB = result.filter(i => i.id.startsWith("b")).length;
|
||||
const fromA = result.filter((i) => i.id.startsWith("a")).length;
|
||||
const fromB = result.filter((i) => i.id.startsWith("b")).length;
|
||||
expect(fromA).toBe(2);
|
||||
expect(fromB).toBe(2);
|
||||
}
|
||||
|
||||
@@ -31,15 +31,15 @@ export function buildHeroMix(
|
||||
pools: MediaItem[][],
|
||||
hasArt: (item: MediaItem) => boolean,
|
||||
count = 6,
|
||||
perPool = 2
|
||||
perPool = 2,
|
||||
): MediaItem[] {
|
||||
const seen = new Set<string>();
|
||||
const usable = pools.map(pool =>
|
||||
pool.filter(item => {
|
||||
const usable = pools.map((pool) =>
|
||||
pool.filter((item) => {
|
||||
if (!hasArt(item) || seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const picked = new Set<string>();
|
||||
@@ -54,6 +54,6 @@ export function buildHeroMix(
|
||||
if (picks.length === 0) return [];
|
||||
|
||||
const [leader, ...rest] = picks;
|
||||
const leftovers = shuffle(usable.flat().filter(item => !picked.has(item.id)));
|
||||
const leftovers = shuffle(usable.flat().filter((item) => !picked.has(item.id)));
|
||||
return [leader, ...shuffle(rest), ...leftovers].slice(0, count);
|
||||
}
|
||||
|
||||
@@ -30,15 +30,8 @@ export interface BottomUiVisibilityInput {
|
||||
* The bottom nav is shown on every authenticated route except the full-screen
|
||||
* player and the login route.
|
||||
*/
|
||||
export function showBottomNav({
|
||||
pathname,
|
||||
isAuthenticated,
|
||||
}: BottomUiVisibilityInput): boolean {
|
||||
return (
|
||||
isAuthenticated &&
|
||||
!pathname.startsWith("/player/") &&
|
||||
!pathname.startsWith("/login")
|
||||
);
|
||||
export function showBottomNav({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean {
|
||||
return isAuthenticated && !pathname.startsWith("/player/") && !pathname.startsWith("/login");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,15 +74,8 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
||||
*
|
||||
* TRACES: UR-054 | DR-076
|
||||
*/
|
||||
export function showGlobalHeader({
|
||||
pathname,
|
||||
isAuthenticated,
|
||||
}: BottomUiVisibilityInput): boolean {
|
||||
return (
|
||||
isAuthenticated &&
|
||||
!routeOwnsLayout({ pathname }) &&
|
||||
!pathname.startsWith("/settings")
|
||||
);
|
||||
export function showGlobalHeader({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean {
|
||||
return isAuthenticated && !routeOwnsLayout({ pathname }) && !pathname.startsWith("/settings");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
placement: 'bottom' | 'top';
|
||||
placement: "bottom" | "top";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,7 @@ export interface MenuPosition {
|
||||
export function calculateMenuPosition(
|
||||
triggerElement: HTMLElement,
|
||||
menuWidth: number = 160,
|
||||
menuHeight: number = 120
|
||||
menuHeight: number = 120,
|
||||
): MenuPosition {
|
||||
const rect = triggerElement.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
@@ -32,20 +32,20 @@ export function calculateMenuPosition(
|
||||
const fitsAbove = spaceAbove >= menuHeight + 8;
|
||||
|
||||
let y: number;
|
||||
let placement: 'bottom' | 'top';
|
||||
let placement: "bottom" | "top";
|
||||
|
||||
if (fitsBelow) {
|
||||
// Prefer below if there's space
|
||||
y = rect.bottom + 4; // 4px gap
|
||||
placement = 'bottom';
|
||||
placement = "bottom";
|
||||
} else if (fitsAbove) {
|
||||
// Show above if no space below
|
||||
y = rect.top - menuHeight - 4; // 4px gap
|
||||
placement = 'top';
|
||||
placement = "top";
|
||||
} else {
|
||||
// Not enough space either way - prefer below and let it extend
|
||||
y = rect.bottom + 4;
|
||||
placement = 'bottom';
|
||||
placement = "bottom";
|
||||
}
|
||||
|
||||
// Horizontal positioning - align right edge of menu with right edge of button
|
||||
|
||||
@@ -79,8 +79,7 @@ describe("native-video compositing layers (DR-185)", () => {
|
||||
.filter((attr) => attr !== "data-native-video");
|
||||
|
||||
const unset = [...new Set(attributes)].filter((attr) => !markup.includes(attr));
|
||||
expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`)
|
||||
.toEqual([]);
|
||||
expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("still sets data-native-video on <html> from the store", () => {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
const goto = vi.fn();
|
||||
// Capture the afterNavigate callback so tests can simulate navigations and thus
|
||||
// drive the in-app depth counter that canGoBack/navigateBack rely on.
|
||||
let afterNavigateCb: ((nav: { from: unknown; to: unknown; delta?: number }) => void) | null =
|
||||
null;
|
||||
let afterNavigateCb: ((nav: { from: unknown; to: unknown; delta?: number }) => void) | null = null;
|
||||
vi.mock("$app/navigation", () => ({
|
||||
goto: (...args: unknown[]) => goto(...args),
|
||||
afterNavigate: (cb: (nav: any) => void) => {
|
||||
|
||||
@@ -114,7 +114,7 @@ export function setHtml5VideoState(
|
||||
active: boolean,
|
||||
width: number,
|
||||
height: number,
|
||||
playing: boolean
|
||||
playing: boolean,
|
||||
): void {
|
||||
try {
|
||||
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
|
||||
|
||||
@@ -212,8 +212,8 @@ describe("safe-area wiring in source", () => {
|
||||
for (const edge of ["top", "right", "bottom", "left"]) {
|
||||
expect(css).toMatch(
|
||||
new RegExp(
|
||||
`--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`
|
||||
)
|
||||
`--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ export function clearScrollMemories(): void {
|
||||
|
||||
export function useScrollRestore(
|
||||
getElement: () => HTMLElement | null | undefined,
|
||||
containerId: string
|
||||
containerId: string,
|
||||
): void {
|
||||
const memory = memoryFor(containerId);
|
||||
|
||||
|
||||
@@ -30,10 +30,7 @@
|
||||
export type NavKind = "enter" | "popstate" | "forward";
|
||||
|
||||
/** What to do with the container once the new route has rendered. */
|
||||
export type ScrollAction =
|
||||
| { kind: "reset" }
|
||||
| { kind: "restore"; top: number }
|
||||
| { kind: "none" };
|
||||
export type ScrollAction = { kind: "reset" } | { kind: "restore"; top: number } | { kind: "none" };
|
||||
|
||||
/**
|
||||
* Collapse SvelteKit's navigation types into the three cases that matter.
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("groupsForScope", () => {
|
||||
"albums",
|
||||
"artists",
|
||||
"people",
|
||||
])
|
||||
]),
|
||||
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
|
||||
});
|
||||
|
||||
@@ -291,7 +291,7 @@ describe("composeSearchGroups", () => {
|
||||
const groups = composeSearchGroups(
|
||||
[{ id: "x", type: null }, { id: "y" }] as { id: string; type?: string | null }[],
|
||||
"all",
|
||||
DEFAULT_GROUP_ORDER
|
||||
DEFAULT_GROUP_ORDER,
|
||||
);
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
@@ -311,13 +311,7 @@ describe("moveGroup", () => {
|
||||
});
|
||||
|
||||
it("moves a group down", () => {
|
||||
expect(moveGroup(order, "songs", 1)).toEqual([
|
||||
"albums",
|
||||
"songs",
|
||||
"artists",
|
||||
"movies",
|
||||
"shows",
|
||||
]);
|
||||
expect(moveGroup(order, "songs", 1)).toEqual(["albums", "songs", "artists", "movies", "shows"]);
|
||||
});
|
||||
|
||||
it("is a no-op at the boundaries", () => {
|
||||
@@ -340,20 +334,8 @@ describe("reorderGroups", () => {
|
||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
||||
|
||||
it("moves an item from one index to another", () => {
|
||||
expect(reorderGroups(order, 0, 4)).toEqual([
|
||||
"albums",
|
||||
"artists",
|
||||
"movies",
|
||||
"shows",
|
||||
"songs",
|
||||
]);
|
||||
expect(reorderGroups(order, 4, 0)).toEqual([
|
||||
"shows",
|
||||
"songs",
|
||||
"albums",
|
||||
"artists",
|
||||
"movies",
|
||||
]);
|
||||
expect(reorderGroups(order, 0, 4)).toEqual(["albums", "artists", "movies", "shows", "songs"]);
|
||||
expect(reorderGroups(order, 4, 0)).toEqual(["shows", "songs", "albums", "artists", "movies"]);
|
||||
});
|
||||
|
||||
it("is a no-op for equal or out-of-range indices", () => {
|
||||
|
||||
@@ -98,7 +98,7 @@ export function shouldNavigateToSearch(pathname: string, query: string): boolean
|
||||
/** Read a `?scope=` value, falling back when it is absent or unrecognised. */
|
||||
export function parseSearchScope(
|
||||
raw: string | null | undefined,
|
||||
fallback: SearchScope = "all"
|
||||
fallback: SearchScope = "all",
|
||||
): SearchScope {
|
||||
return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export interface SearchSeed {
|
||||
*/
|
||||
export function seedFromSearchUrl(
|
||||
params: URLSearchParams,
|
||||
applied: SearchSeed | null
|
||||
applied: SearchSeed | null,
|
||||
): SearchSeed | null {
|
||||
const seed: SearchSeed = {
|
||||
query: params.get("q") ?? "",
|
||||
@@ -141,13 +141,7 @@ export function seedFromSearchUrl(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SearchGroupId =
|
||||
| "shows"
|
||||
| "episodes"
|
||||
| "movies"
|
||||
| "songs"
|
||||
| "albums"
|
||||
| "artists"
|
||||
| "people";
|
||||
"shows" | "episodes" | "movies" | "songs" | "albums" | "artists" | "people";
|
||||
|
||||
/**
|
||||
* Shipped default order.
|
||||
@@ -268,12 +262,12 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
|
||||
/** Groups visible under a scope, in the user's configured order. */
|
||||
export function groupsForScope(
|
||||
scope: SearchScope,
|
||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER,
|
||||
): SearchGroupId[] {
|
||||
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
|
||||
// only under `all` — the `=== scope` test already excludes it elsewhere.
|
||||
return normalizeGroupOrder(order as SearchGroupId[]).filter(
|
||||
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
||||
(id) => scope === "all" || GROUP_SCOPE[id] === scope,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,7 +286,7 @@ export interface SearchGroup<T> {
|
||||
export function composeSearchGroups<T extends { type?: string | null }>(
|
||||
results: readonly T[],
|
||||
scope: SearchScope,
|
||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER,
|
||||
): SearchGroup<T>[] {
|
||||
return groupsForScope(scope, order)
|
||||
.map((id) => {
|
||||
@@ -310,7 +304,7 @@ export function composeSearchGroups<T extends { type?: string | null }>(
|
||||
export function moveGroup(
|
||||
order: readonly SearchGroupId[],
|
||||
id: SearchGroupId,
|
||||
delta: number
|
||||
delta: number,
|
||||
): SearchGroupId[] {
|
||||
const next = [...order];
|
||||
const from = next.indexOf(id);
|
||||
@@ -325,7 +319,7 @@ export function moveGroup(
|
||||
export function reorderGroups(
|
||||
order: readonly SearchGroupId[],
|
||||
from: number,
|
||||
to: number
|
||||
to: number,
|
||||
): SearchGroupId[] {
|
||||
const next = [...order];
|
||||
if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next;
|
||||
|
||||
@@ -87,7 +87,12 @@ export function validateUrlPathSegment(segment: string): void {
|
||||
/**
|
||||
* Validate numeric parameter (width, height, quality, etc.)
|
||||
*/
|
||||
export function validateNumericParam(value: unknown, min = 0, max = 10000, name = "parameter"): number {
|
||||
export function validateNumericParam(
|
||||
value: unknown,
|
||||
min = 0,
|
||||
max = 10000,
|
||||
name = "parameter",
|
||||
): number {
|
||||
// Must be an actual number, not a string that looks like a number
|
||||
if (typeof value !== "number") {
|
||||
throw new Error(`Invalid ${name}: must be an integer`);
|
||||
|
||||
@@ -78,7 +78,7 @@ export function enableNativeVideoCompositing(): void {
|
||||
// console bridge forwards this to logcat under the JellyTauWeb tag.
|
||||
log.error(
|
||||
"AndroidVideoSurface bridge is MISSING - the webview will " +
|
||||
"stay opaque and native video will play as audio with no picture"
|
||||
"stay opaque and native video will play as audio with no picture",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user