import { describe, expect, it, vi } from "vitest"; import { createBrowserHostViewport, createMemoryHostViewport, HOST_COMPACT_MEDIA_QUERY, } from "../src/browser/host-viewport.js"; import { createLocalSidebarPreference, createMemorySidebarPreference, SIDEBAR_PREFERENCE_KEY, } from "../src/browser/sidebar-preference.js"; function fakeStorage(initial: Record = {}) { const values = new Map(Object.entries(initial)); return { getItem: vi.fn((key: string) => values.get(key) ?? null), setItem: vi.fn((key: string, value: string) => { values.set(key, value); }), values, }; } describe("the sidebar preference (this browser only, ADR 0058)", () => { it("survives a reload: what one store writes, a new store over the same storage reads", () => { const storage = fakeStorage(); createLocalSidebarPreference(storage).write("collapsed"); expect(storage.values.get(SIDEBAR_PREFERENCE_KEY)).toBe("collapsed"); expect(createLocalSidebarPreference(storage).read()).toBe("collapsed"); createLocalSidebarPreference(storage).write("expanded"); expect(createLocalSidebarPreference(storage).read()).toBe("expanded"); }); it("uses one namespaced key that names no person", () => { expect(SIDEBAR_PREFERENCE_KEY).toBe("ekklesia.dashboard.sidebar"); }); it("ignores a stored value it does not recognise rather than trusting it", () => { for (const value of ["", "COLLAPSED", "true", "{}", "collapsed "]) { expect( createLocalSidebarPreference(fakeStorage({ [SIDEBAR_PREFERENCE_KEY]: value })).read(), value, ).toBeUndefined(); } }); it("keeps working, for this page only, when storage is missing or throws", () => { const throwing = { getItem: () => { throw new Error("SecurityError"); }, setItem: () => { throw new Error("QuotaExceededError"); }, }; for (const storage of [undefined, throwing]) { const preference = createLocalSidebarPreference(storage); expect(preference.read()).toBeUndefined(); expect(() => preference.write("collapsed")).not.toThrow(); expect(preference.read()).toBe("collapsed"); } }); it("has an in-memory store with the same contract, for tests and as the fallback", () => { const preference = createMemorySidebarPreference("collapsed"); expect(preference.read()).toBe("collapsed"); preference.write("expanded"); expect(preference.read()).toBe("expanded"); expect(createMemorySidebarPreference().read()).toBeUndefined(); }); }); describe("the host viewport", () => { it("is compact below 48em, the phone layout's breakpoint", () => { expect(HOST_COMPACT_MEDIA_QUERY).toBe("(max-width: 47.999em)"); }); it("reads and follows a media query list", () => { let matches = false; const listeners = new Set<() => void>(); const mediaQuery = { addEventListener: vi.fn((_type: "change", listener: () => void) => listeners.add(listener)), get matches() { return matches; }, removeEventListener: vi.fn((_type: "change", listener: () => void) => listeners.delete(listener), ), }; const matchMedia = vi.fn(() => mediaQuery); const viewport = createBrowserHostViewport(matchMedia); expect(matchMedia).toHaveBeenCalledWith(HOST_COMPACT_MEDIA_QUERY); const listener = vi.fn(); const unsubscribe = viewport.subscribe(listener); expect(viewport.isCompact()).toBe(false); matches = true; for (const notify of listeners) { notify(); } expect(listener).toHaveBeenCalledTimes(1); expect(viewport.isCompact()).toBe(true); unsubscribe(); expect(mediaQuery.removeEventListener).toHaveBeenCalledTimes(1); }); it("falls back to the wide layout where media queries are unavailable", () => { const viewport = createBrowserHostViewport(undefined); expect(viewport.isCompact()).toBe(false); expect(() => viewport.subscribe(() => undefined)()).not.toThrow(); }); it("has an in-memory viewport a test can resize", () => { const viewport = createMemoryHostViewport(true); const listener = vi.fn(); viewport.subscribe(listener); viewport.setCompact(false); expect(viewport.isCompact()).toBe(false); expect(listener).toHaveBeenCalledTimes(1); viewport.setCompact(false); expect(listener).toHaveBeenCalledTimes(1); }); });