layout and remote fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m29s
Build & Release / Build Linux (push) Successful in 17m27s
Build & Release / Build Android (push) Successful in 22m14s
Build & Release / Create Release (push) Successful in 12s

This commit is contained in:
2026-07-16 22:53:03 +02:00
parent 532ffa661a
commit 1992a8187d
10 changed files with 272 additions and 192 deletions
+38 -47
View File
@@ -1,13 +1,15 @@
/**
* Regression tests for the app's fixed bottom-UI (mini player + bottom nav)
* layout rules.
* Tests for the app's bottom-UI (mini player + bottom nav) visibility 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`.
* 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
*/
@@ -18,7 +20,6 @@ import {
showGlobalMiniPlayer,
routeOwnsLayout,
showBottomUi,
reservedBottomPadding,
} from "./layoutShell";
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
@@ -44,15 +45,14 @@ describe("showGlobalMiniPlayer", () => {
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.
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", () => {
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);
@@ -69,64 +69,55 @@ describe("showBottomNav", () => {
});
describe("routeOwnsLayout", () => {
it("is true for library/settings/player/login (they manage their own scroll)", () => {
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: "/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", () => {
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("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).
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}: 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.
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 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).
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);
});
});
describe("reservedBottomPadding", () => {
it("returns an exact px fit when no extra room requested", () => {
expect(reservedBottomPadding(120)).toBe("120px");
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("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)");
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);
});
});