lib/app-layout.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment jsdom
import { APP_LAYOUT_BOOTSTRAP } from "./app-layout-bootstrap.generated";
import { afterEach, describe, expect, it, vi } from "vitest";
import { APP_LAYOUT_POLICY, isMobileLayout, resolveMobileLayout, subscribeAppLayout, type AppLayoutInput } from "./app-layout";
const input = (width: number, height: number, touch = true, orientation: AppLayoutInput["orientation"] = height >= width ? "portrait" : "landscape"): AppLayoutInput => ({ width, height, touch, screenWidth: width, screenHeight: height, orientation });
const cases: [string, AppLayoutInput, boolean][] = [
["narrow phone cover", input(280, 653), true],
["phone portrait", input(390, 844), true],
["phone landscape", input(844, 390), true],
["wide phone landscape", input(1100, 500), true],
["foldable inner square", input(896, 896), true],
["foldable inner landscape", input(960, 800), true],
["large unfolded landscape", input(1100, 800), false],
["tablet portrait", input(820, 1180), true],
["large tablet portrait", input(1032, 1376), true],
["large tablet landscape", input(1376, 1032), false],
["small tablet landscape", input(1024, 768), false],
["tablet split window", input(700, 1032), true],
["wide tablet portrait boundary", input(1199, 1500), true],
["roomy portrait viewport", input(1200, 1600), false],
["desktop below boundary", input(1023, 900, false), true],
["desktop at boundary", input(1024, 900, false), false],
["desktop portrait window", input(1100, 1500, false), false],
["desktop short window", input(1366, 500, false), false],
];
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); delete document.documentElement.dataset.appLayout; });
function emulate(value: AppLayoutInput) {
vi.stubGlobal("innerWidth", value.width);
vi.stubGlobal("innerHeight", value.height);
vi.stubGlobal("navigator", { maxTouchPoints: value.touch ? 5 : 0 });
const orientation = new EventTarget() as EventTarget & { type: string };
orientation.type = value.orientation === "portrait" ? "portrait-primary" : "landscape-primary";
vi.stubGlobal("screen", { width: value.screenWidth, height: value.screenHeight, orientation: value.orientation ? orientation : undefined });
vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("max-width") ? value.width < 1024 : value.touch, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
return orientation;
}
describe("adaptive application layout", () => {
it.each(cases)("selects the expected layout for %s", (_name, value, mobile) => {
expect(resolveMobileLayout(value, APP_LAYOUT_POLICY)).toBe(mobile);
emulate(value);
expect(isMobileLayout()).toBe(mobile);
// The pre-paint script must agree with the runtime decision in every case.
new Function(APP_LAYOUT_BOOTSTRAP)();
expect(document.documentElement.dataset.appLayout).toBe(mobile ? "mobile" : "desktop");
});
it("keeps tablet orientation stable when a keyboard changes viewport shape", () => {
const portrait = { ...input(1032, 1376), height: 620 };
expect(resolveMobileLayout(portrait, APP_LAYOUT_POLICY)).toBe(true);
const landscape = { ...input(1376, 1032), height: 450 };
expect(resolveMobileLayout(landscape, APP_LAYOUT_POLICY)).toBe(false);
});
it("falls back to screen shape without orientation APIs", () => {
expect(resolveMobileLayout({ ...input(1032, 1376), height: 600, orientation: null }, APP_LAYOUT_POLICY)).toBe(true);
expect(resolveMobileLayout({ ...input(1100, 800), height: 600, orientation: null }, APP_LAYOUT_POLICY)).toBe(false);
});
it("supports the legacy Safari orientation signal", () => {
emulate({ ...input(1032, 1376), height: 600, orientation: null });
vi.stubGlobal("orientation", 0);
expect(isMobileLayout()).toBe(true);
vi.stubGlobal("orientation", 90);
expect(isMobileLayout()).toBe(false);
});
it("shares rotation/resize listeners and removes them after the final subscriber", () => {
const orientation = emulate(input(1032, 1376));
const first = vi.fn(), second = vi.fn();
const add = vi.spyOn(window, "addEventListener"), remove = vi.spyOn(window, "removeEventListener");
const stopFirst = subscribeAppLayout(first), stopSecond = subscribeAppLayout(second);
expect(add.mock.calls.filter(([name]) => name === "resize")).toHaveLength(1);
expect(document.documentElement.dataset.appLayout).toBe("mobile");
orientation.type = "landscape-primary";
vi.stubGlobal("innerWidth", 1376);
vi.stubGlobal("matchMedia", () => ({ matches: false }));
orientation.dispatchEvent(new Event("change"));
expect(document.documentElement.dataset.appLayout).toBe("desktop");
expect(second).toHaveBeenCalledOnce();
stopFirst();
window.dispatchEvent(new Event("resize"));
expect(second).toHaveBeenCalledTimes(2);
stopSecond();
expect(remove.mock.calls.filter(([name]) => name === "resize")).toHaveLength(1);
window.dispatchEvent(new Event("resize"));
expect(second).toHaveBeenCalledTimes(2);
});
});