🏗️ 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
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.
205 lines
8.7 KiB
TypeScript
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);
|
|
});
|
|
});
|