Switching was reachable only from Settings, which is the wrong place for it: on a shared device changing who is watching is a frequent, front-door action, not a configuration change buried three screens deep. It sits directly under the identity block rather than among the destinations (Downloads/Settings/Display), because it answers "who is this?" and not "where do I go?". Shown even with one account, since the picker is also where a second is added -- gating it on a second profile existing would leave no way in from here. The picker also gains a Back affordance when a session is already live. Reaching it from the menu and changing your mind -- or failing a PIN on someone else's tile -- previously had no way back to the session you still had. At startup there is nothing behind it, so it stays hidden.
143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
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(["Switch profile", "Downloads", "Settings", "Display", "Sign out"]);
|
|
});
|
|
|
|
/**
|
|
* "Switch profile" sits directly under the identity block, before the
|
|
* destinations. It answers "who is this?", not "where do I go?", and burying
|
|
* it in Settings is what this entry exists to undo.
|
|
*
|
|
* TRACES: UR-082 | DR-276
|
|
*/
|
|
it("puts Switch profile first, next to the identity block", async () => {
|
|
render(AccountMenu);
|
|
openMenu();
|
|
const items = screen.getAllByRole("menuitem");
|
|
expect(items[0].textContent?.trim()).toBe("Switch profile");
|
|
expect(items[0].getAttribute("href")).toBe("/profiles");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|