Files
WRNexusJS/packages/viewer/test/actions.test.ts
T

209 lines
8.3 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { clearViewerActions, registerViewerAction } from "../src/actions.ts";
import { mountViewer } from "../src/runtime.ts";
import { iconFor } from "../src/renderers/icons.ts";
async function settled(ticks = 30) {
for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5));
}
function fixture() {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
return { window, host };
}
const csv = () => ({ name: "data.csv", blob: new Blob(["a,b\n1,2\n"]) });
const labels = (host: HTMLElement) =>
[...host.querySelectorAll(".wrn-viewer-action")].map((node) => node.getAttribute("aria-label"));
afterEach(() => clearViewerActions());
describe("icon buttons", () => {
test("every control is an icon button with an accessible name and a tooltip", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
const buttons = [...host.querySelectorAll(".wrn-viewer-action")];
expect(buttons.length).toBeGreaterThan(0);
for (const node of buttons) {
const label = node.getAttribute("aria-label") ?? "";
expect(label).toBeTruthy();
// The tooltip shows the same name the assistive label reads out.
expect(node.getAttribute("data-tooltip")).toBe(label);
expect(node.querySelector("svg")).not.toBeNull();
// The name stays in the DOM for screen readers, visually hidden.
expect(node.querySelector(".wrn-viewer-sr")!.textContent).toBe(label);
}
dispose();
await window.happyDOM.close();
});
test("toggling a label swaps the icon instead of deleting it", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
const toggle = [...host.querySelectorAll<HTMLButtonElement>(".wrn-viewer-action")].find(
(node) => node.getAttribute("aria-label") === "Raw",
)!;
toggle.click();
expect(toggle.getAttribute("aria-label")).toBe("Table");
expect(toggle.getAttribute("data-tooltip")).toBe("Table");
expect(toggle.querySelector("svg")).not.toBeNull();
dispose();
await window.happyDOM.close();
});
test("an unknown label still gets an icon rather than nothing", () => {
expect(iconFor("Zoom in")).toBe("zoom-in");
expect(iconFor("Something bespoke")).toBe("file");
});
});
describe("custom actions", () => {
test("a globally registered action appears and runs with the render context", async () => {
const { window, host } = fixture();
const seen: string[] = [];
registerViewerAction({
id: "open-in-tab",
label: "Open in new tab",
run: ({ source, renderer }) => {
seen.push(`${source.name}:${renderer?.component}`);
},
});
const dispose = mountViewer(host, csv());
await settled();
const action = host.querySelector<HTMLButtonElement>('[data-viewer-action="open-in-tab"]')!;
expect(action).not.toBeNull();
expect(action.getAttribute("aria-label")).toBe("Open in new tab");
action.click();
expect(seen).toEqual(["data.csv:TableViewer"]);
dispose();
await window.happyDOM.close();
});
test("`when` decides, and per-viewer actions are offered too", async () => {
const { window, host } = fixture();
registerViewerAction({
id: "images-only",
label: "Crop",
when: ({ kind }) => kind === "image",
run: () => {},
});
const dispose = mountViewer(host, csv(), {
actions: [{ id: "local", label: "Send", placement: "controls", run: () => {} }],
});
await settled();
expect(host.querySelector('[data-viewer-action="images-only"]')).toBeNull();
expect(host.querySelector('[data-viewer-action="local"]')).not.toBeNull();
dispose();
await window.happyDOM.close();
});
test("actions survive a re-render without piling up", async () => {
const { window, host } = fixture();
registerViewerAction({ id: "once", label: "Once", run: () => {} });
const dispose = mountViewer(host, csv());
await settled();
const toggle = [...host.querySelectorAll<HTMLButtonElement>(".wrn-viewer-action")].find(
(node) => node.getAttribute("aria-label") === "Raw",
)!;
toggle.click();
expect(host.querySelectorAll('[data-viewer-action="once"]')).toHaveLength(1);
dispose();
await window.happyDOM.close();
});
test("an app action is still offered for a file nothing can preview", async () => {
const { window, host } = fixture();
registerViewerAction({ id: "anywhere", label: "Report", run: () => {} });
const dispose = mountViewer(host, {
name: "thing.bin",
blob: new Blob([new Uint8Array([0, 1, 2])]),
});
await settled();
expect(host.querySelector('[data-viewer-action="anywhere"]')).not.toBeNull();
// Printing a download card would print nothing, so it is withheld.
expect(host.querySelector('[data-viewer-action="print"]')).toBeNull();
dispose();
await window.happyDOM.close();
});
});
describe("print", () => {
test("a rendered preview offers Print; it is opt-out", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
expect(labels(host)).toContain("Print");
dispose();
const second = fixture();
const disposeSecond = mountViewer(second.host, csv(), { print: false });
await settled();
expect(labels(second.host)).not.toContain("Print");
disposeSecond();
await second.window.happyDOM.close();
await window.happyDOM.close();
});
test("printing isolates a repaired clone of the preview", async () => {
const { window, host } = fixture();
let printed = 0;
(window as unknown as { print: () => void }).print = () => {
printed++;
const doc = window.document;
// At the moment of printing, a clone of the preview stands alone in the
// body and the page is marked so everything else drops out of the flow.
expect(doc.documentElement.hasAttribute("data-viewer-printing-active")).toBe(true);
const root = doc.querySelector("[data-viewer-print-root]")!;
expect(root).not.toBeNull();
expect(root.textContent).toContain("data.csv");
expect(root.querySelector("table")).not.toBeNull();
// Spacer rows stand in for rows that were never in the DOM.
expect(root.querySelectorAll("[data-viewer-spacer]")).toHaveLength(0);
};
const dispose = mountViewer(host, csv());
await settled();
host.querySelector<HTMLButtonElement>('[data-viewer-action="print"]')!.click();
expect(printed).toBe(1);
// Cleanup waits for the browser to finish, then leaves the page as found.
window.dispatchEvent(new window.Event("afterprint") as never);
expect(window.document.querySelector("[data-viewer-print-root]")).toBeNull();
expect(window.document.documentElement.hasAttribute("data-viewer-printing-active")).toBe(false);
dispose();
await window.happyDOM.close();
});
});
describe("audio transport", () => {
test("one set of controls drives the playhead, not two", async () => {
const { window, host } = fixture();
const wav = new Uint8Array(64);
wav.set(new TextEncoder().encode("RIFF"));
wav.set(new TextEncoder().encode("WAVE"), 8);
const dispose = mountViewer(host, { name: "clip.wav", blob: new Blob([wav as never]) });
await settled();
// The native controls are gone: they were a second play/seek for the same
// element, sitting above the package's own transport.
expect(host.querySelector("audio")!.hasAttribute("controls")).toBe(false);
const player = host.querySelector(".wrn-viewer-player")!;
expect(player).not.toBeNull();
expect(
[...player.querySelectorAll(".wrn-viewer-action")].map((n) => n.getAttribute("aria-label")),
).toEqual(["Play", "Mute"]);
// Exactly one seek control, and it is labelled.
const seeks = host.querySelectorAll('input[type="range"]');
expect(seeks).toHaveLength(1);
expect(seeks[0]!.getAttribute("aria-label")).toContain("clip.wav");
const play = player.querySelector<HTMLButtonElement>(".wrn-viewer-play")!;
expect(play.querySelector("svg")).not.toBeNull();
dispose();
await window.happyDOM.close();
});
});