# test.web/presentation/components/panel.component.test.ts

> 217 lines of code and 0 definitions.

Tree: Codebase Testing
Language: typescript
Canonical: https://banes-lab.com/anatomy/testing#file-testing-test-web-presentation-components-panel-component-test-ts
Source text: https://banes-lab.com/source/testing/test.web/presentation/components/panel.component.test.ts.txt

Listed in [test.web/presentation/components](https://banes-lab.com/api/source/testing/test.web/presentation/components.md), after [test.web/presentation/components/overlay.component.test.ts](https://banes-lab.com/source/testing/test.web/presentation/components/overlay.component.test.ts.md) and before [test.web/presentation/components/reference.component.test.ts](https://banes-lab.com/source/testing/test.web/presentation/components/reference.component.test.ts.md).

## Contained in

- [test.web/presentation/components](https://banes-lab.com/anatomy/testing/folder-testing-test-web-presentation-components.md)

## Source

```typescript
import {
    ORIGIN_VIEW,
    boundedView,
    centerOf,
    contentExtentOf,
    distanceBetween,
    extentOf,
    fittedView,
    midpointOf,
    zoomedView,
} from "@banes-lab/web/core/analyzers/panel.analyzer.ts";
import {
    PANEL_ARMED_CLASS,
    PANEL_ARM_MS,
    PANEL_DRAGGING_CLASS,
    PANEL_MAX_SCALE,
    PANEL_MIN_SCALE,
    PANEL_REVEAL_EVENT,
    PANEL_REVEAL_MARGIN_PX,
    PANEL_SCALE_PROPERTY,
    PANEL_X_PROPERTY,
    PANEL_Y_PROPERTY,
    PANEL_ZOOM_STEP,
} from "@banes-lab/web/configuration/constants/panel.constants.ts";
import { RESET_VIEW_TITLE, ZOOM_IN_TITLE, ZOOM_OUT_TITLE } from "@banes-lab/web/configuration/strings/panel.strings.ts";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { PanelHandle } from "@banes-lab/web/types/panel.types.ts";
import { PanelState } from "@banes-lab/web/presentation/components/stage.component.ts";
import { createPanel } from "@banes-lab/web/presentation/components/panel.component.ts";
import { declaredStyle } from "@banes-lab/web/core/registries/style.registry.ts";
import { observeArming } from "@banes-lab/web/core/observers/panel.observer.ts";

const VIEWPORT = { height: 100, width: 200 };
const CONTENT = { height: 400, width: 600 };
const CLASS_NAME = "sample-panel";

const rectOf = function rectOf(width: number, height: number): DOMRect {
    return { bottom: height, height, left: 0, right: width, toJSON: () => ({}), top: 0, width, x: 0, y: 0 };
};

const sized = function sized(mode: "fill" | "fit" | "origin", content = CONTENT): PanelHandle {
    const inner = document.createElement("div");
    const panel = createPanel(inner, CLASS_NAME, mode);
    const zoom = panel.element.querySelector<HTMLElement>(".panel-zoom");
    if (zoom === null) {
        throw new Error("The panel has no zoom layer.");
    }
    panel.element.getBoundingClientRect = () => rectOf(VIEWPORT.width, VIEWPORT.height);
    zoom.getBoundingClientRect = () => rectOf(content.width * panel.view().scale, content.height * panel.view().scale);
    return panel;
};

const pointer = function pointer(type: string, id: number, x: number, y: number, pointerType = "touch"): MouseEvent {
    const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y });
    Object.defineProperty(event, "pointerId", { value: id });
    Object.defineProperty(event, "pointerType", { value: pointerType });
    return event;
};

const wheel = function wheel(deltaX: number, deltaY: number, ctrlKey = false): WheelEvent {
    return new WheelEvent("wheel", {
        bubbles: true,
        cancelable: true,
        clientX: 0,
        clientY: 0,
        ctrlKey,
        deltaX,
        deltaY,
    });
};

const control = function control(panel: PanelHandle, title: string): HTMLButtonElement {
    const button = panel.element.querySelector<HTMLButtonElement>(`button[title="${title}"]`);
    if (button === null) {
        throw new Error(`No control titled ${title}.`);
    }
    return button;
};

const armed = function armed(panel: PanelHandle): PanelHandle {
    vi.useFakeTimers();
    panel.element.dispatchEvent(new Event("pointerenter"));
    vi.advanceTimersByTime(PANEL_ARM_MS);
    return panel;
};

beforeAll(() => {
    Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { configurable: true, value: () => {} });
});

afterEach(() => {
    vi.useRealTimers();
});

describe("the panel view math", () => {
    it("bounds a view inside the viewport, zooms around an anchor, fits and centers, and measures pointer pairs", () => {
        expect(boundedView({ scale: 1, x: -900, y: 50 }, VIEWPORT, CONTENT)).toStrictEqual({ scale: 1, x: -400, y: 0 });
        expect(zoomedView(ORIGIN_VIEW, 2, { x: 100, y: 50 })).toStrictEqual({ scale: 2, x: -100, y: -50 });
        expect(fittedView(VIEWPORT, { height: 0, width: 0 })).toStrictEqual(ORIGIN_VIEW);
        expect(centerOf(VIEWPORT)).toStrictEqual({ x: 100, y: 50 });
        expect(distanceBetween({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
        expect(midpointOf({ x: 0, y: 0 }, { x: 4, y: 2 })).toStrictEqual({ x: 2, y: 1 });
        const element = document.createElement("div");
        element.getBoundingClientRect = () => rectOf(300, 150);
        expect(extentOf(element)).toStrictEqual({ height: 150, width: 300 });
        expect(contentExtentOf(element, 3)).toStrictEqual({ height: 50, width: 100 });
    });
});

describe("createPanel", () => {
    it("wraps the content in a stage, carries the class and starts at the origin with the view written as custom properties", () => {
        const panel = sized("origin");
        const stage = panel.element.querySelector<HTMLElement>(".panel-stage");
        expect(panel.element.classList.contains(CLASS_NAME)).toBe(true);
        expect(stage?.firstElementChild?.tagName).toBe("DIV");
        expect(panel.view()).toStrictEqual({ scale: 1, x: 0, y: 0 });
        const staged = stage ?? panel.element;
        expect(declaredStyle(staged, PANEL_X_PROPERTY)).toBe("0px");
        expect(declaredStyle(staged, PANEL_Y_PROPERTY)).toBe("0px");
        expect(declaredStyle(staged, PANEL_SCALE_PROPERTY)).toBe("1");
    });

    it("pans by the pointer's travel, never past the content's edge, and marks the drag while a pointer is down", () => {
        const panel = sized("origin");
        panel.element.dispatchEvent(pointer("pointerdown", 1, 50, 50));
        expect(panel.element.classList.contains(PANEL_DRAGGING_CLASS)).toBe(true);
        panel.element.dispatchEvent(pointer("pointermove", 1, 20, 10));
        expect(panel.view()).toStrictEqual({ scale: 1, x: -30, y: -40 });
        panel.element.dispatchEvent(pointer("pointermove", 1, -1000, -1000));
        expect(panel.view()).toStrictEqual({
            scale: 1,
            x: VIEWPORT.width - CONTENT.width,
            y: VIEWPORT.height - CONTENT.height,
        });
        panel.element.dispatchEvent(pointer("pointerup", 1, -1000, -1000));
        expect(panel.element.classList.contains(PANEL_DRAGGING_CLASS)).toBe(false);
    });

    it("keeps content smaller than the viewport inside it", () => {
        const panel = sized("origin", { height: 20, width: 50 });
        panel.element.dispatchEvent(pointer("pointerdown", 1, 0, 0));
        panel.element.dispatchEvent(pointer("pointermove", 1, 500, 500));
        expect(panel.view()).toStrictEqual({ scale: 1, x: VIEWPORT.width - 50, y: VIEWPORT.height - 20 });
    });

    it("ignores the wheel and a mouse drag until the pointer has rested on it, and disarms on leave", () => {
        vi.useFakeTimers();
        const panel = sized("origin");
        const early = wheel(0, 30);
        panel.element.dispatchEvent(new Event("pointerenter"));
        vi.advanceTimersByTime(PANEL_ARM_MS - 1);
        panel.element.dispatchEvent(early);
        expect(early.defaultPrevented).toBe(false);
        expect(panel.view().y).toBe(0);
        vi.advanceTimersByTime(1);
        expect(panel.element.classList.contains(PANEL_ARMED_CLASS)).toBe(true);
        panel.element.dispatchEvent(wheel(0, 30));
        expect(panel.view().y).toBe(-30);
        panel.element.dispatchEvent(new Event("pointerleave"));
        expect(panel.element.classList.contains(PANEL_ARMED_CLASS)).toBe(false);
        panel.element.dispatchEvent(pointer("pointerdown", 1, 0, 0, "mouse"));
        expect(panel.element.classList.contains(PANEL_DRAGGING_CLASS)).toBe(false);
        const plain = document.createElement("div");
        const isArmed = observeArming(plain);
        plain.dispatchEvent(new Event("pointerenter"));
        vi.advanceTimersByTime(PANEL_ARM_MS);
        expect(isArmed()).toBe(true);
    });

    it("zooms around the anchor with the controls and the wheel, within the scale bounds", () => {
        const panel = armed(sized("origin"));
        control(panel, ZOOM_IN_TITLE).click();
        expect(panel.view().scale).toBeCloseTo(PANEL_ZOOM_STEP);
        control(panel, ZOOM_OUT_TITLE).click();
        expect(panel.view().scale).toBeCloseTo(1);
        for (let i = 0; i < 20; i += 1) {
            control(panel, ZOOM_OUT_TITLE).click();
        }
        expect(panel.view().scale).toBe(PANEL_MIN_SCALE);
        for (let i = 0; i < 40; i += 1) {
            panel.element.dispatchEvent(wheel(0, -1000, true));
        }
        expect(panel.view().scale).toBe(PANEL_MAX_SCALE);
        control(panel, RESET_VIEW_TITLE).click();
        expect(panel.view()).toStrictEqual({ scale: 1, x: 0, y: 0 });
    });

    it("scrolls the view with a plain wheel and keeps the wheel contained at the edge", () => {
        const panel = armed(sized("origin"));
        const inside = wheel(0, 30);
        panel.element.dispatchEvent(inside);
        expect(inside.defaultPrevented).toBe(true);
        expect(panel.view().y).toBe(-30);
        const past = wheel(0, 10_000);
        panel.element.dispatchEvent(past);
        expect(panel.view().y).toBe(VIEWPORT.height - CONTENT.height);
        const beyond = wheel(0, 10);
        panel.element.dispatchEvent(beyond);
        expect(beyond.defaultPrevented).toBe(true);
        expect(panel.view().y).toBe(VIEWPORT.height - CONTENT.height);
    });

    it("pinches with two pointers, scaling by the change in their distance", () => {
        const panel = sized("origin");
        panel.element.dispatchEvent(pointer("pointerdown", 1, 0, 0));
        panel.element.dispatchEvent(pointer("pointerdown", 2, 10, 0));
        panel.element.dispatchEvent(pointer("pointermove", 2, 20, 0));
        expect(panel.view().scale).toBeCloseTo(2);
        panel.element.dispatchEvent(pointer("pointerup", 2, 20, 0));
        panel.element.dispatchEvent(pointer("pointerup", 1, 0, 0));
        expect(panel.element.classList.contains(PANEL_DRAGGING_CLASS)).toBe(false);
    });

    it("fits and centers the content on reset in fit mode, never enlarging past its natural size", () => {
        const large = sized("fit");
        large.reset();
        expect(large.view().scale).toBeCloseTo(VIEWPORT.height / CONTENT.height);
        expect(large.view().x).toBeCloseTo((VIEWPORT.width - CONTENT.width * large.view().scale) / 2);
        expect(large.view().y).toBeCloseTo(0);
        const small = sized("fit", { height: 20, width: 50 });
        small.reset();
        expect(small.view()).toStrictEqual({ scale: 1, x: (VIEWPORT.width - 50) / 2, y: (VIEWPORT.height - 20) / 2 });
    });

    it("enlarges small content up to the viewport in fill mode, and pans to a revealed offset", () => {
        const panel = sized("fill", { height: 20, width: 50 });
        panel.reset();
        expect(panel.view().scale).toBeCloseTo(PANEL_MAX_SCALE);
        const state = new PanelState(document.createElement("div"), document.createElement("div"), "origin");
        state.reveal(50);
        expect(state.view.y).toBe(0);
        const tall = sized("origin");
        tall.element.dispatchEvent(new CustomEvent(PANEL_REVEAL_EVENT, { detail: 200 }));
        expect(tall.view().y).toBe(PANEL_REVEAL_MARGIN_PX - 200);
    });
});
```
