Files
jellytau/src/lib/utils/layoutShell.test.ts
T
dtourolle 1b70926c36
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
2026-08-09 16:38:07 +02:00

205 lines
8.7 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);
});
});