// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import { announcePopout, openPopout, popoutUrl, popoutWindowName, watchPopout } from "./popout"; const ID = "11111111-2222-3333-4444-555555555555"; const OTHER = "99999999-2222-3333-4444-555555555555"; // BroadcastChannel delivers asynchronously; give it a turn of the event loop. const settle = () => new Promise((r) => setTimeout(r, 20)); const cleanups: (() => void)[] = []; afterEach(() => { while (cleanups.length) cleanups.pop()!(); }); describe("popout URLs", () => { it("builds the live page URL and a per-camera window name", () => { expect(popoutUrl(ID, 500)).toBe(`/cameras/${ID}/live?refresh=500`); expect(popoutWindowName(ID)).toBe(`camera-${ID}`); }); }); describe("openPopout", () => { it("opens a popup sized to the stream's aspect ratio and focuses it", () => { vi.spyOn(window.screen, "availWidth", "get").mockReturnValue(2000); const focus = vi.fn(); const open = vi.spyOn(window, "open").mockReturnValue({ focus } as unknown as Window); expect(openPopout(ID, 1000, 4096 / 1860)).toBe(true); expect(open).toHaveBeenCalledWith(`/cameras/${ID}/live?refresh=1000`, `camera-${ID}`, "popup,width=1280,height=581"); expect(focus).toHaveBeenCalled(); }); it("uses 16:9 and at most 80% of a small screen", () => { vi.spyOn(window.screen, "availWidth", "get").mockReturnValue(1000); const open = vi.spyOn(window, "open").mockReturnValue({ focus: vi.fn() } as unknown as Window); openPopout(ID, 1000); expect(open.mock.calls[0][2]).toBe("popup,width=800,height=450"); }); it("uses the default width when the screen size is unknown", () => { vi.spyOn(window.screen, "availWidth", "get").mockReturnValue(0); const open = vi.spyOn(window, "open").mockReturnValue({ focus: vi.fn() } as unknown as Window); openPopout(ID, 1000); expect(open.mock.calls[0][2]).toBe("popup,width=1280,height=720"); }); it("reports a blocked popup", () => { vi.spyOn(window, "open").mockReturnValue(null); expect(openPopout(ID, 1000)).toBe(false); }); }); describe("pop-out and card coordination", () => { it("tells a watching card when its camera's pop-out opens and closes", async () => { const onChange = vi.fn(); cleanups.push(watchPopout(ID, onChange)); const close = announcePopout(ID); await settle(); expect(onChange).toHaveBeenLastCalledWith(true); close(); await settle(); expect(onChange).toHaveBeenLastCalledWith(false); }); it("answers a card that starts watching after the pop-out opened", async () => { cleanups.push(announcePopout(ID)); await settle(); const onChange = vi.fn(); cleanups.push(watchPopout(ID, onChange)); await settle(); expect(onChange).toHaveBeenCalledWith(true); }); it("ignores other cameras' pop-outs", async () => { const onChange = vi.fn(); cleanups.push(watchPopout(ID, onChange)); cleanups.push(announcePopout(OTHER)); await settle(); expect(onChange).not.toHaveBeenCalled(); }); it("announces closing when the window goes away", async () => { const onChange = vi.fn(); cleanups.push(watchPopout(ID, onChange)); cleanups.push(announcePopout(ID)); await settle(); window.dispatchEvent(new Event("pagehide")); await settle(); expect(onChange).toHaveBeenLastCalledWith(false); }); it("does nothing where BroadcastChannel is unavailable", () => { vi.stubGlobal("BroadcastChannel", undefined); const onChange = vi.fn(); expect(() => { watchPopout(ID, onChange)(); announcePopout(ID)(); }).not.toThrow(); expect(onChange).not.toHaveBeenCalled(); }); });