Add tests covering driver, overlay and popover branches

This commit is contained in:
Kamran Ahmed
2026-07-06 16:08:15 +01:00
parent ffbf6a41ad
commit dab8de6b9c
5 changed files with 231 additions and 2 deletions
+50
View File
@@ -179,3 +179,53 @@ describe("button interactions", () => {
expect(d.isActive()).toBe(true);
});
});
describe("overlay click behaviour", () => {
it("closes the tour when the overlay is clicked and behaviour is 'close'", async () => {
const d = createDriver({ animate: false, overlayClickBehavior: "close", steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
clickOverlay();
expect(d.isActive()).toBe(false);
});
it("runs a custom overlayClickBehavior with the active element, step and driver", async () => {
const overlayClickBehavior = vi.fn();
const d = createDriver({ animate: false, overlayClickBehavior, steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
clickOverlay();
expect(overlayClickBehavior).toHaveBeenCalledTimes(1);
const [element, step, options] = overlayClickBehavior.mock.calls[0];
expect(element).toBe(document.querySelector("#intro"));
expect(step.popover?.title).toBe("Step 1");
expect(options.driver).toBe(d);
expect(d.isActive()).toBe(true);
});
it("advances to the next step when 'nextStep' overlay is clicked without onNextClick", async () => {
const d = createDriver({ animate: false, overlayClickBehavior: "nextStep", steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
clickOverlay();
expect(d.getActiveIndex()).toBe(1);
});
it("runs onDoneClick when a 'nextStep' overlay is clicked on the final step", async () => {
const onDoneClick = vi.fn();
const d = createDriver({ animate: false, overlayClickBehavior: "nextStep", onDoneClick, steps: SAMPLE_STEPS });
d.drive(SAMPLE_STEPS.length - 1);
await nextFrame();
clickOverlay();
expect(onDoneClick).toHaveBeenCalledTimes(1);
expect(d.isActive()).toBe(true);
});
});
+33
View File
@@ -72,4 +72,37 @@ describe("keyboard control", () => {
pressKey("Escape");
expect(d.isActive()).toBe(true);
});
it("runs onPrevClick instead of moving back when ArrowLeft is pressed", async () => {
const onPrevClick = vi.fn();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onPrevClick });
d.drive(1);
await nextFrame();
pressKey("ArrowLeft");
expect(onPrevClick).toHaveBeenCalledTimes(1);
expect(d.getActiveIndex()).toBe(1);
});
it("runs onNextClick instead of advancing when ArrowRight is pressed", async () => {
const onNextClick = vi.fn();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onNextClick });
d.drive();
await nextFrame();
pressKey("ArrowRight");
expect(onNextClick).toHaveBeenCalledTimes(1);
expect(d.getActiveIndex()).toBe(0);
});
it("ignores arrow keys while a highlight transition is in flight", () => {
const d = createDriver({ animate: true, steps: SAMPLE_STEPS });
d.drive();
// No frame awaited: the transition callback is still set, so arrows no-op.
pressKey("ArrowRight");
expect(d.getActiveIndex()).toBe(0);
});
});
+68 -2
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { createDriver, popoverDescription, popoverEl, popoverTitle, useDriverHarness } from "./utils";
import { describe, expect, it, vi } from "vitest";
import {
createDriver,
nextFrame,
popoverDescription,
popoverEl,
popoverTitle,
pressKey,
SAMPLE_STEPS,
useDriverHarness,
} from "./utils";
useDriverHarness();
@@ -77,3 +86,60 @@ describe("lifecycle", () => {
expect(d.getActiveIndex()).toBeUndefined();
});
});
describe("drive guards", () => {
it("logs an error and stays inactive when there are no steps", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const d = createDriver({ animate: false });
d.drive();
expect(error).toHaveBeenCalledWith("No steps to drive through");
expect(d.isActive()).toBe(false);
error.mockRestore();
});
it("destroys instead of driving an out-of-range step index", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive(99);
expect(d.isActive()).toBe(false);
});
it("ignores moveNext and movePrevious before the tour starts", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.moveNext();
d.movePrevious();
expect(d.isActive()).toBe(false);
expect(d.getActiveIndex()).toBeUndefined();
});
});
describe("dummy-element hooks", () => {
it("passes an undefined element to onDeselected and onDestroyed for a dummy highlight", async () => {
const onDeselected = vi.fn();
const onDestroyed = vi.fn();
const d = createDriver({ animate: false, onDeselected, onDestroyed });
d.highlight({ popover: { title: "Modal" } });
await nextFrame();
d.destroy();
expect(onDeselected).toHaveBeenCalledTimes(1);
expect(onDestroyed).toHaveBeenCalledTimes(1);
expect(onDeselected.mock.calls[0][0]).toBeUndefined();
expect(onDestroyed.mock.calls[0][0]).toBeUndefined();
});
it("passes an undefined element to onDestroyStarted for a dummy highlight", async () => {
const onDestroyStarted = vi.fn();
const d = createDriver({ animate: false, onDestroyStarted });
d.highlight({ popover: { title: "Modal" } });
await nextFrame();
pressKey("Escape");
expect(onDestroyStarted).toHaveBeenCalledTimes(1);
expect(onDestroyStarted.mock.calls[0][0]).toBeUndefined();
});
});
+28
View File
@@ -51,4 +51,32 @@ describe("overlay configuration", () => {
expect(overlayPath()?.getAttribute("d")).toContain("M-15,-20");
});
it("falls back to a solid black fill when overlayColor is empty", async () => {
const d = createDriver({ animate: false, overlayColor: "", steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
expect(overlayPath()?.style.fill).toBe("rgb(0, 0, 0)");
});
it("draws square corners with no inset when padding and radius are zero", async () => {
const d = createDriver({ animate: false, stagePadding: 0, stageRadius: 0, steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
// Zero radius removes the rounded-corner arcs and the cutout starts at the origin.
expect(overlayPath()?.getAttribute("d")).toContain("M0,0");
expect(overlayPath()?.getAttribute("d")).not.toContain("a5,5");
});
it("does not close the tour when the backdrop outside the cutout path is clicked", async () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
document.querySelector(".driver-overlay")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(d.isActive()).toBe(true);
});
});
+52
View File
@@ -187,6 +187,58 @@ describe("popover rendering", () => {
});
});
describe("popover interaction edge cases", () => {
it("disables the close button when close is in disableButtons", () => {
const d = createDriver({ animate: false });
d.highlight({
element: "#intro",
popover: { title: "Intro", showButtons: ["close"], disableButtons: ["close"] },
});
expect(navButton("close")?.disabled).toBe(true);
expect(navButton("close")?.classList.contains("driver-popover-btn-disabled")).toBe(true);
});
it("emits without navigating when next/prev are clicked on a bare highlight", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro", showButtons: ["next", "previous"] } });
navButton("next")?.click();
navButton("prev")?.click();
expect(d.isActive()).toBe(true);
expect(d.getActiveElement()).toBe(document.querySelector("#intro"));
});
it("lets links inside the description behave normally", () => {
const d = createDriver({ animate: false });
d.highlight({
element: "#intro",
popover: { title: "Intro", description: '<a class="doc-link" href="#doc">Docs</a>' },
});
document.querySelector<HTMLElement>(".doc-link")?.click();
expect(d.isActive()).toBe(true);
});
it("repositions the popover when a lazy image inside it finishes loading", () => {
let lazyImage: HTMLImageElement | undefined;
const d = createDriver({
animate: false,
onPopoverRender: popover => {
lazyImage = document.createElement("img");
Object.defineProperty(lazyImage, "complete", { value: false });
popover.description.appendChild(lazyImage);
},
});
d.highlight({ element: "#intro", popover: { title: "Intro", description: "With image" } });
expect(() => lazyImage?.dispatchEvent(new Event("load"))).not.toThrow();
expect(popoverEl()).not.toBeNull();
});
});
describe("popover arrow", () => {
const rect = (over: Partial<DOMRect>): DOMRect =>
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;