/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.
239 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
});
|