/** * Tests for the app's bottom-UI (mini player + bottom nav) visibility rules. * * The overlap bug these guard against: on the library page the last rows were * hidden behind the bottom nav. It was caused by rendering the bottom UI as a * FIXED overlay and trying to reserve its (async-measured, initially-0) height * as padding. The fix renders the bottom UI as an in-flow flex child below the * scroller, so overlap is structurally impossible — no measurement, no padding. * * These pure functions only decide *whether* each piece shows on a route. The * structural guarantee (flex sibling below the scroller) is exercised by * running the app, not by jsdom (which has no layout engine). * * TRACES: UR-005 | DR-009 */ import { describe, it, expect } from "vitest"; import { showBottomNav, showGlobalMiniPlayer, showGlobalHeader, showHeaderSearch, routeOwnsLayout, showBottomUi, shellReservesBottomInset, } 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 — one code path everywhere", () => { // The old bug was a platform/route split that let a second mini player exist. expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true); }); }); describe("showBottomNav", () => { it("shows on authenticated content routes including library and settings", () => { 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/player/login (they render their own flex column + BottomUi)", () => { expect(routeOwnsLayout({ pathname: "/library" })).toBe(true); expect(routeOwnsLayout({ pathname: "/library/abc" })).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 (incl. settings)", () => { // Settings has no +layout of its own; it must flow through the root scroller // so the root's in-flow BottomUi renders below it (otherwise settings loses // its nav, since the fixed-overlay nav no longer exists). expect(routeOwnsLayout({ pathname: "/" })).toBe(false); expect(routeOwnsLayout({ pathname: "/search" })).toBe(false); expect(routeOwnsLayout({ pathname: "/downloads" })).toBe(false); expect(routeOwnsLayout({ pathname: "/settings" })).toBe(false); }); }); describe("showGlobalHeader", () => { it("shows the root-owned header (with account menu) on home, search, downloads", () => { // The whole point of UR-054: account actions reachable without going to Library. expect(showGlobalHeader(authed("/"))).toBe(true); expect(showGlobalHeader(authed("/search"))).toBe(true); expect(showGlobalHeader(authed("/downloads"))).toBe(true); }); it("does NOT show on library (it renders its own AppHeader)", () => { expect(showGlobalHeader(authed("/library"))).toBe(false); expect(showGlobalHeader(authed("/library/abc"))).toBe(false); }); it("does NOT show on settings (owns its content; no account menu needed there)", () => { expect(showGlobalHeader(authed("/settings"))).toBe(false); }); it("does NOT show on the immersive player or login", () => { expect(showGlobalHeader(authed("/player/x"))).toBe(false); expect(showGlobalHeader(authed("/login"))).toBe(false); }); it("hides when unauthenticated", () => { expect(showGlobalHeader({ pathname: "/", isAuthenticated: false })).toBe(false); }); }); describe("structural invariant: every route that shows bottom UI has a scroller above it", () => { // With the in-flow model, "the bottom UI is a flex sibling below a scroller" // must hold on every route where it shows. That scroller is provided by // exactly one owner: // - routeOwnsLayout === true -> the route's own column (header + main + BottomUi) // - routeOwnsLayout === false -> the root column (scroller + BottomUi) // The forbidden state — bottom UI shows but no owning column renders a // scroller + BottomUi pair — cannot occur because the two branches are total. const routes = ["/", "/search", "/downloads", "/library", "/library/abc", "/settings"]; for (const pathname of routes) { it(`${pathname}: bottom UI shows and has a defined layout owner`, () => { expect(showBottomUi(authed(pathname))).toBe(true); expect(typeof routeOwnsLayout({ pathname })).toBe("boolean"); }); } it("library owns its layout, so it renders its own in-flow BottomUi", () => { // Directly pins the original regression: library must render BottomUi inside // its own column (the root gives owning routes a clipped, non-scrolling box). expect(showBottomUi(authed("/library"))).toBe(true); expect(routeOwnsLayout({ pathname: "/library" })).toBe(true); }); it("settings does NOT own its layout, so the root scroller + BottomUi cover it", () => { expect(showBottomUi(authed("/settings"))).toBe(true); expect(routeOwnsLayout({ pathname: "/settings" })).toBe(false); }); it("the full-screen player shows no bottom UI and owns its layout", () => { expect(showBottomUi(authed("/player/x"))).toBe(false); expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true); }); }); /** * Who owns the bottom safe-area inset. * * TRACES: UR-066 | DR-112 | UT-098 * * Exactly one element must reserve `--safe-bottom`, or the Android gesture bar * is either ignored (nav swallowed) or double-padded (a dead strip above it). * BottomUi owns it whenever it renders — the padding sits inside its surface * box so the colour extends behind the bar. Routes with no BottomUi (login, the * full-screen player) leave the app shell to reserve it instead. */ describe("shellReservesBottomInset", () => { it("defers to BottomUi on every route that renders one", () => { for (const pathname of ["/", "/search", "/downloads", "/settings", "/library"]) { expect(showBottomUi(authed(pathname))).toBe(true); expect(shellReservesBottomInset(authed(pathname))).toBe(false); } }); it("reserves the inset itself on routes with no bottom UI", () => { expect(shellReservesBottomInset(authed("/login"))).toBe(true); expect(shellReservesBottomInset(authed("/player/x"))).toBe(true); }); it("defers on the unauthenticated shell, where the mini player still renders", () => { expect(shellReservesBottomInset({ pathname: "/", isAuthenticated: false })).toBe(false); }); }); describe("showHeaderSearch", () => { it("renders the bar on the library routes", () => { expect(showHeaderSearch({ pathname: "/library" })).toBe(true); expect(showHeaderSearch({ pathname: "/library/music/albums" })).toBe(true); expect(showHeaderSearch({ pathname: "/library/abc123" })).toBe(true); }); it("keeps the bar on /search, so searching does not swap you to another input", () => { // The regression: the bar existed only under /library, so a header search // landed the user on /search with the box they were typing in gone and a // different one (belonging to the page) in its place. expect(showHeaderSearch({ pathname: "/search" })).toBe(true); expect(showHeaderSearch({ pathname: "/search/" })).toBe(true); }); it("stays off routes with nothing to search", () => { expect(showHeaderSearch({ pathname: "/" })).toBe(false); expect(showHeaderSearch({ pathname: "/downloads" })).toBe(false); expect(showHeaderSearch({ pathname: "/settings" })).toBe(false); }); });