209 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|