Files
jellytau/src/lib/utils/safeArea.test.ts
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

257 lines
9.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import {
ZERO_INSETS,
parseNativeInsets,
safeAreaCssVars,
applySafeAreaInsets,
readNativeInsets,
initSafeArea,
INSETS_CHANGED_EVENT,
} from "./safeArea";
/**
* Safe-area (window inset) plumbing for Android.
*
* TRACES: UR-066 | IR-031, DR-112 | UT-094, UT-095, UT-096, UT-097
*
* Regression guard for "the bottom nav is off the bottom of the screen on some
* devices (Motorola) but not others (Fairphone)".
*
* Two independent defects produced it:
*
* 1. `src/app.html` shipped `<meta name="viewport" content="width=device-width,
* initial-scale=1">` — no `viewport-fit=cover`. Per the CSS Env spec, every
* `env(safe-area-inset-*)` resolves to **0px** unless the viewport opts into
* `cover`. So the `env()` padding in app.css and BottomUi.svelte was a
* no-op on every device.
* 2. Even with `viewport-fit=cover`, Android WebView only maps the **display
* cutout** into `env(safe-area-inset-*)` — never the status bar or the
* navigation/gesture bar. MainActivity calls `enableEdgeToEdge()` and the
* app targets SDK 36 (edge-to-edge is mandatory from SDK 35 and the opt-out
* is ignored from SDK 36), so the WebView always spans the full window
* including the system bars. CSS alone can never learn about them.
*
* The device split was only in how much the bars intrude: a thin translucent
* gesture pill overlaps harmlessly, a tall opaque 3-button bar swallows the nav
* outright. Both devices were equally unpadded.
*
* The fix pushes real `WindowInsets` from Kotlin into CSS custom properties.
* These tests pin the frontend half of that contract.
*/
describe("parseNativeInsets", () => {
it("parses the JSON payload the native bridge returns", () => {
expect(parseNativeInsets('{"top":24,"right":0,"bottom":48,"left":0}')).toEqual({
top: 24,
right: 0,
bottom: 48,
left: 0,
});
});
it("accepts an already-parsed object", () => {
expect(parseNativeInsets({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual({
top: 1,
right: 2,
bottom: 3,
left: 4,
});
});
it("treats missing, non-finite and negative edges as zero rather than emitting NaN", () => {
// A NaN would serialise to "NaNpx" and silently kill the whole padding
// declaration, which is exactly the failure mode being guarded against.
expect(parseNativeInsets('{"top":-5,"bottom":"48"}')).toEqual({
top: 0,
right: 0,
bottom: 48,
left: 0,
});
expect(parseNativeInsets({ top: Number.NaN, right: Infinity })).toEqual(ZERO_INSETS);
});
it("returns null for input that is not an inset payload at all", () => {
expect(parseNativeInsets("not json")).toBeNull();
expect(parseNativeInsets(null)).toBeNull();
expect(parseNativeInsets(undefined)).toBeNull();
expect(parseNativeInsets(42)).toBeNull();
});
});
describe("safeAreaCssVars / applySafeAreaInsets", () => {
it("emits px-suffixed custom properties for all four edges", () => {
expect(safeAreaCssVars({ top: 24, right: 0, bottom: 48, left: 12 })).toEqual({
"--jt-inset-top": "24px",
"--jt-inset-right": "0px",
"--jt-inset-bottom": "48px",
"--jt-inset-left": "12px",
});
});
it("writes the custom properties onto the target element", () => {
const el = document.createElement("div");
applySafeAreaInsets(el, { top: 24, right: 1, bottom: 48, left: 2 });
expect(el.style.getPropertyValue("--jt-inset-top")).toBe("24px");
expect(el.style.getPropertyValue("--jt-inset-right")).toBe("1px");
expect(el.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
expect(el.style.getPropertyValue("--jt-inset-left")).toBe("2px");
});
});
describe("readNativeInsets", () => {
beforeEach(() => {
delete (window as unknown as Record<string, unknown>).AndroidInsets;
vi.restoreAllMocks();
});
it("returns null when the bridge is absent (desktop, iOS, dev server)", () => {
expect(readNativeInsets()).toBeNull();
});
it("reads and parses the bridge payload", () => {
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
expect(readNativeInsets()).toEqual({ top: 24, right: 0, bottom: 48, left: 0 });
});
it("returns null when the bridge object is a stale WebView proxy", () => {
// Same failure mode as the background-audio bridge: the injected object
// stays truthy across a page load while its methods vanish. Must not throw
// out of layout init.
vi.spyOn(console, "warn").mockImplementation(() => {});
window.AndroidInsets = {} as unknown as { get(): string };
expect(readNativeInsets()).toBeNull();
window.AndroidInsets = {
get: () => {
throw new TypeError("get is not a function");
},
};
expect(readNativeInsets()).toBeNull();
});
});
describe("initSafeArea", () => {
let stop: (() => void) | null = null;
beforeEach(() => {
delete (window as unknown as Record<string, unknown>).AndroidInsets;
document.documentElement.removeAttribute("style");
vi.restoreAllMocks();
});
afterEach(() => {
stop?.();
stop = null;
});
it("primes the document element from the bridge on start", () => {
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
stop = initSafeArea();
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
});
it("re-applies insets when native reports a change (rotation, nav-mode switch)", () => {
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
window.AndroidInsets = { get: () => payload };
stop = initSafeArea();
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
// Rotated to landscape: the cutout moves to the left edge, the gesture bar
// shrinks. Native re-pushes and fires the change event.
payload = '{"top":0,"right":0,"bottom":24,"left":44}';
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("0px");
expect(document.documentElement.style.getPropertyValue("--jt-inset-left")).toBe("44px");
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("24px");
});
it("leaves the custom properties unset with no bridge, so env() keeps the field", () => {
// On iOS/desktop the `env(safe-area-inset-*)` half of the max() must win;
// writing an explicit 0px here would clobber it.
stop = initSafeArea();
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("");
});
it("stops listening once torn down", () => {
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
window.AndroidInsets = { get: () => payload };
const teardown = initSafeArea();
teardown();
payload = '{"top":99,"right":99,"bottom":99,"left":99}';
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
});
});
/**
* Static guards. The two root-cause defects were both single lines of markup /
* CSS that no runtime test could reach, so pin them at the source level.
*/
describe("safe-area wiring in source", () => {
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), "utf8");
it("app.html opts the viewport into viewport-fit=cover", () => {
const viewport = read("src/app.html").match(/<meta\s+name="viewport"[^>]*>/i)?.[0];
expect(viewport, "no viewport meta tag found in src/app.html").toBeTruthy();
expect(viewport).toMatch(/viewport-fit\s*=\s*cover/);
});
it("app.css derives --safe-* from both env() and the native --jt-inset-* vars", () => {
const css = read("src/app.css");
for (const edge of ["top", "right", "bottom", "left"]) {
expect(css).toMatch(
new RegExp(
`--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`,
),
);
}
});
it("no component pads directly from env() — everything goes through --safe-*", () => {
// A bare env() is 0 in Android WebView for the system bars, which is the
// bug. app.css is the one legal place it may appear (inside the max()).
const offenders = [
"src/lib/components/BottomUi.svelte",
"src/routes/+layout.svelte",
"src/lib/components/player/VideoPlayer.svelte",
"src/lib/components/player/AudioPlayer.svelte",
].filter((f) => read(f).includes("env(safe-area-inset"));
expect(offenders).toEqual([]);
});
it("the bottom UI reserves the bottom inset so the nav clears the gesture bar", () => {
expect(read("src/lib/components/BottomUi.svelte")).toMatch(/pb-\[var\(--safe-bottom\)\]/);
});
it("only the app shell measures itself against the viewport", () => {
// The shell is `h-screen` AND inset-padded, so its content box is
// `100vh - safe-top`. Any nested `h-screen`/`min-h-screen` is therefore
// taller than the space it was given and overflows by exactly the inset —
// the library column's `h-screen` clipped its own BottomUi that way. Nested
// full-height boxes must use `h-full`/`min-h-full` and inherit the shell's
// already-inset height.
const svelteFilesIn = (dir: string): string[] =>
readdirSync(resolve(process.cwd(), dir), { recursive: true, encoding: "utf8" })
.filter((f) => f.endsWith(".svelte"))
.map((f) => `${dir}/${f}`);
const offenders = [...svelteFilesIn("src/routes"), ...svelteFilesIn("src/lib/components")]
.filter((f) => f !== "src/routes/+layout.svelte")
.filter((f) => /class=[^>]*\bh-screen\b|class=[^>]*\bmin-h-screen\b/.test(read(f)));
expect(offenders).toEqual([]);
});
});