cameras/src/app/popout.test.ts
Michael Mainguy ebd488b672 Add camera pop-out window
- "Pop out" on each dashboard card opens /cameras/[id]/live in a named
  popup sized to the main stream (a plain link if popups are blocked).
- The live page shows only the frame, never stretched past its own size,
  with a fading name/time/refresh-rate overlay and error handling.
- The card pauses its own polling while its pop-out is open; the two
  coordinate over a BroadcastChannel.
- Share frame rendering (FrameImage) between card and pop-out.
- Ignore a stray build cache folder; export vrek log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:02:39 -05:00

101 lines
3.6 KiB
TypeScript

// @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();
});
});