Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped

This commit is contained in:
2026-07-11 19:55:55 +02:00
parent a2cd9978f0
commit 2a1f1689b4
20 changed files with 991 additions and 995 deletions
+132
View File
@@ -0,0 +1,132 @@
/**
* Regression tests for the app's fixed bottom-UI (mini player + bottom nav)
* layout rules.
*
* The bug these guard against: on the library route the layout used to render
* its OWN in-flow mini player while the root ALSO painted a fixed bottom nav on
* top of it, and the library scroller only reserved 1rem — so the last row hid
* behind the nav. The fix unified everything onto the root: the root owns the
* single fixed bottom UI on every route/platform, and every scroll container
* reserves the measured `bottomUiHeight`.
*
* TRACES: UR-005 | DR-009
*/
import { describe, it, expect } from "vitest";
import {
showBottomNav,
showGlobalMiniPlayer,
routeOwnsLayout,
showBottomUi,
reservedBottomPadding,
} from "./layoutShell";
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
describe("showGlobalMiniPlayer", () => {
it("shows on the main library page (regression: was hidden on non-Android)", () => {
expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true);
});
it("shows on a deep library page", () => {
expect(showGlobalMiniPlayer({ pathname: "/library/abc123" })).toBe(true);
});
it("shows on home, search, downloads", () => {
expect(showGlobalMiniPlayer({ pathname: "/" })).toBe(true);
expect(showGlobalMiniPlayer({ pathname: "/search" })).toBe(true);
expect(showGlobalMiniPlayer({ pathname: "/downloads" })).toBe(true);
});
it("hides on the full-screen player, login, and settings", () => {
expect(showGlobalMiniPlayer({ pathname: "/player/xyz" })).toBe(false);
expect(showGlobalMiniPlayer({ pathname: "/login" })).toBe(false);
expect(showGlobalMiniPlayer({ pathname: "/settings" })).toBe(false);
});
it("does NOT depend on platform or on /library — the root owns it everywhere", () => {
// The signature intentionally has no `isAndroid` input: the old bug was a
// platform/route split that let a second in-flow mini player exist.
expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true);
});
});
describe("showBottomNav", () => {
it("shows on authenticated content routes including library", () => {
expect(showBottomNav(authed("/library"))).toBe(true);
expect(showBottomNav(authed("/"))).toBe(true);
expect(showBottomNav(authed("/settings"))).toBe(true);
});
it("hides when unauthenticated", () => {
expect(showBottomNav({ pathname: "/library", isAuthenticated: false })).toBe(false);
});
it("hides on the full-screen player and login", () => {
expect(showBottomNav(authed("/player/xyz"))).toBe(false);
expect(showBottomNav(authed("/login"))).toBe(false);
});
});
describe("routeOwnsLayout", () => {
it("is true for library/settings/player/login (they manage their own scroll)", () => {
expect(routeOwnsLayout({ pathname: "/library" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/library/abc" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/settings" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/login" })).toBe(true);
});
it("is false for routes that render into the root scroller", () => {
expect(routeOwnsLayout({ pathname: "/" })).toBe(false);
expect(routeOwnsLayout({ pathname: "/search" })).toBe(false);
expect(routeOwnsLayout({ pathname: "/downloads" })).toBe(false);
});
});
describe("layout invariant: a reservation owner exists wherever bottom UI shows", () => {
// The core anti-regression check. Every route falls into exactly one of two
// reservation regimes:
// - route owns its layout -> the route's own scroller reserves bottomUiHeight
// - route does NOT own it -> the root scroller reserves bottomUiHeight
// The bug was that the library route was implicitly a THIRD regime: it owned
// its layout, showed a fixed nav from the root, but reserved only 1rem. That
// can't recur now because library both owns its layout (so it reserves
// internally) and the mini player is root-owned (no second in-flow bar).
const routes = ["/", "/search", "/downloads", "/library", "/library/abc", "/settings"];
for (const pathname of routes) {
it(`${pathname}: exactly one reservation owner`, () => {
if (!showBottomUi(authed(pathname))) return; // no bottom UI -> nothing to reserve
// Ownership is a total boolean, so exactly one regime always applies —
// there is no route that shows bottom UI with no reservation owner.
expect(typeof routeOwnsLayout({ pathname })).toBe("boolean");
});
}
it("library shows bottom UI AND owns its layout, so it reserves internally", () => {
// Directly pins the regression: library must NOT rely on the root scroller
// (it has none — the root gives owning routes a clipped, non-scrolling box).
expect(showBottomUi(authed("/library"))).toBe(true);
expect(routeOwnsLayout({ pathname: "/library" })).toBe(true);
});
});
describe("reservedBottomPadding", () => {
it("returns an exact px fit when no extra room requested", () => {
expect(reservedBottomPadding(120)).toBe("120px");
});
it("adds breathing room via calc for layout-owning routes", () => {
expect(reservedBottomPadding(120, 1)).toBe("calc(120px + 1rem)");
});
it("never returns negative padding", () => {
expect(reservedBottomPadding(-50)).toBe("0px");
expect(reservedBottomPadding(-50, 1)).toBe("calc(0px + 1rem)");
});
it("reserves 1rem-only when the bottom UI is collapsed to 0 (nothing playing, nav-only measured elsewhere)", () => {
expect(reservedBottomPadding(0, 1)).toBe("calc(0px + 1rem)");
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* Pure layout-shell logic for the app's fixed bottom UI (mini player stacked
* over the bottom nav).
*
* These rules used to live as inline `$derived` booleans scattered across the
* root and library `+layout.svelte` files, and diverged per platform/route —
* which is exactly how the "last row hidden behind the nav" bug kept coming
* back. The invariant is now a single source of truth:
*
* - The ROOT layout owns the single fixed bottom UI on every route/platform.
* There is no per-route/per-platform second mini player.
* - Whatever fixed bottom UI is showing has a live-measured height
* (`bottomUiHeight`), and every scroll container reserves exactly that much
* bottom space so the last row can never render behind the nav.
*
* Keeping this pure makes the invariant unit-testable (jsdom has no layout
* engine, so the geometry itself can't be tested — but the decision logic can).
*
* TRACES: UR-005 | DR-009
*/
export interface BottomUiVisibilityInput {
/** Current route pathname, e.g. `$page.url.pathname`. */
pathname: string;
/** Whether the user is authenticated. */
isAuthenticated: boolean;
}
/**
* 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")
);
}
/**
* The global (root-owned) mini player is shown on every route except the
* full-screen player, login, and settings. Crucially this is NOT gated on
* platform or on `/library` — the root owns the mini player everywhere, so the
* library route must never render its own second one.
*/
export function showGlobalMiniPlayer({ pathname }: { pathname: string }): boolean {
return (
!pathname.startsWith("/player/") &&
!pathname.startsWith("/login") &&
!pathname.startsWith("/settings")
);
}
/**
* Routes that own their own full-height layout (their own scroll container +
* bottom-space reservation). The root leaves these as a plain non-scrolling box
* and does NOT add bottom padding — the route reserves `bottomUiHeight` itself.
* Every other route scrolls in the root wrapper, which reserves the space.
*/
export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
return (
pathname.startsWith("/library") ||
pathname.startsWith("/settings") ||
pathname.startsWith("/player/") ||
pathname.startsWith("/login")
);
}
/**
* Whether any fixed bottom UI is showing for this route (mini player, nav, or
* both). When true, the active scroll container must reserve `bottomUiHeight`.
*/
export function showBottomUi(input: BottomUiVisibilityInput): boolean {
return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname });
}
/**
* The bottom padding (in CSS) a scroll container must reserve so its last row
* clears the fixed bottom UI. `bottomUiHeight` is the live-measured height of
* the root's fixed bottom UI wrapper.
*
* @param bottomUiHeight measured height in px of the fixed bottom UI (0 if none)
* @param extraRem breathing room added on top (routes that own their
* layout add 1rem; the root wrapper reserves an exact fit)
*/
export function reservedBottomPadding(
bottomUiHeight: number,
extraRem = 0,
): string {
const px = Math.max(0, bottomUiHeight);
return extraRem > 0 ? `calc(${px}px + ${extraRem}rem)` : `${px}px`;
}