feat(chrome): shared account menu and global app header
Move account actions (Settings, Downloads, Display preferences, Sign out) out of the library-only header into a shared AccountMenu anchored in a global AppHeader, available on every authenticated non-immersive screen. Add a layoutShell helper deciding where chrome shows, expose serverName/serverUrl auth stores, and a display view-mode preference. The settings page also gains the UR-053 WiFi-only toggle. TRACES: UR-054 | DR-075, DR-076, DR-077
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
|
||||
// Controllable store shims + spies, declared via vi.hoisted so they exist when
|
||||
// the hoisted vi.mock factories run. A tiny writable shim avoids importing
|
||||
// svelte inside the hoisted block.
|
||||
const h = vi.hoisted(() => {
|
||||
function shim<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
set(v: T) {
|
||||
value = v;
|
||||
subs.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(fn: (v: T) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
currentUserStore: shim<{ name: string } | null>({ name: "Ada" }),
|
||||
serverNameStore: shim<string | null>("Home Server"),
|
||||
serverUrlStore: shim<string | null>("https://media.example.com"),
|
||||
logout: vi.fn(async () => {}),
|
||||
reset: vi.fn(),
|
||||
goto: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: { logout: h.logout },
|
||||
currentUser: { subscribe: h.currentUserStore.subscribe },
|
||||
serverName: { subscribe: h.serverNameStore.subscribe },
|
||||
serverUrl: { subscribe: h.serverUrlStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/library", () => ({
|
||||
library: { reset: h.reset },
|
||||
}));
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: h.goto }));
|
||||
|
||||
import AccountMenu from "./AccountMenu.svelte";
|
||||
|
||||
function openMenu() {
|
||||
const trigger = screen.getByRole("button", { name: "Account menu" });
|
||||
fireEvent.click(trigger);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
describe("AccountMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.currentUserStore.set({ name: "Ada" });
|
||||
h.serverNameStore.set("Home Server");
|
||||
h.serverUrlStore.set("https://media.example.com");
|
||||
});
|
||||
|
||||
it("trigger toggles aria-expanded", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = screen.getByRole("button", { name: "Account menu" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
await fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
await fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders the documented items in order", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
|
||||
});
|
||||
|
||||
it("shows the identity block with name and server host", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
expect(screen.getByText("Signed in as")).toBeTruthy();
|
||||
// "Ada" appears in both the trigger label and the identity block.
|
||||
expect(screen.getAllByText("Ada").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Home Server")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the URL host when no server name is set", async () => {
|
||||
h.serverNameStore.set(null);
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
expect(screen.getByText("media.example.com")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Escape closes the menu", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = openMenu();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
await fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("backdrop click closes the menu", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = openMenu();
|
||||
const backdrop = screen.getByRole("button", { name: "Close account menu" });
|
||||
await fireEvent.click(backdrop);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("Sign out logs out, resets library state, and redirects home", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const signOut = screen.getByRole("menuitem", { name: "Sign out" });
|
||||
await fireEvent.click(signOut);
|
||||
expect(h.logout).toHaveBeenCalledOnce();
|
||||
expect(h.reset).toHaveBeenCalledOnce();
|
||||
expect(h.goto).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("Sign out is the last item, after the routine navigation", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items[items.length - 1]).toBe("Sign out");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user