Files
jellytau/src/lib/utils/layoutShell.test.ts
T
dtourolle fb3d0014ef fix(profiles): keep the picker chrome-free
/profiles was unknown to the layout shell, so reaching it from Settings
rendered the bottom nav and global header over the picker -- letting
someone tab straight past the profile they were being asked to choose,
and offering an in-app mini player as a route back into the previous
profile's queue.

Grouped with /login throughout layoutShell: it is a gate, not a page. OS
lockscreen transport controls are unaffected, which is what keeps playing
audio controllable while the app is locked.
2026-08-30 19:30:51 +02:00

239 lines
9.9 KiB
TypeScript

/**
* 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);
});
});
/**
* The profile picker is a gate, not a page. It must be chrome-free for the same
* reason /login is: bottom nav over a "who's watching" screen lets someone tab
* straight past the profile they were being asked to choose, and an in-app mini
* player on it would offer a route into the previous profile's queue. OS
* lockscreen transport controls are unaffected — those are what keep playing
* audio controllable while the app is locked (DR-275).
*
* TRACES: UR-082 | DR-276
*/
describe("profile picker chrome", () => {
const pathname = "/profiles";
it("shows no bottom nav, even authenticated", () => {
expect(showBottomNav({ pathname, isAuthenticated: true })).toBe(false);
});
it("shows no global mini player", () => {
expect(showGlobalMiniPlayer({ pathname })).toBe(false);
});
it("shows no global header", () => {
expect(showGlobalHeader({ pathname, isAuthenticated: true })).toBe(false);
});
it("owns its own layout", () => {
expect(routeOwnsLayout({ pathname })).toBe(true);
});
it("lets the shell reserve the bottom inset, since nothing else will", () => {
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
});
});