Files
2026-07-13 12:25:07 +08:00

569 lines
21 KiB
TypeScript

// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/**
* Click-event emission for `viewer-charts`. Asserts the
* `<perspective-viewer>` host element dispatches
* `perspective-click` (`CustomEvent<PerspectiveClickDetail>`) and
* `perspective-global-filter` (`CustomEvent<PerspectiveSelectDetail>`)
* with detail shapes compatible with `viewer-datagrid`'s equivalents.
*
* Coordinate strategy: every chart loaded here is configured so that a
* click at the canvas centerpoint reliably lands on a single glyph
* (one bar across the plot rect, a single treemap leaf filling most
* of the area, etc.). Tests assert detail *shape* rather than exact
* values for the volatile fields (the precise row index / category
* label depends on canvas-size + hit-test geometry); the filter
* structure (column count, `[col, "==", value]` triples, presence of
* `row` keys) is what consumers actually code against.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "@perspective-dev/test";
import type { ViewerConfigUpdate } from "@perspective-dev/viewer";
import { gotoBasic, restoreChart, waitOneFrame } from "./helpers";
/**
* Plain-object snapshot of a captured `CustomEvent.detail`. We
* postMessage these between page and Playwright contexts so class
* instances are flattened — assertions only inspect own fields, not
* the `PerspectiveSelectDetail` prototype getters.
*/
interface CapturedEvent {
name: string;
detail: any;
detailIsSelect: boolean;
}
/**
* Install listeners on `<perspective-viewer>` for the named events.
* Subsequent `drainCapturedEvents` reads them back as plain objects.
*/
async function installEventCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const v = document.querySelector("perspective-viewer")!;
const events: any[] = [];
const capture = (name: string) => (e: Event) => {
const ce = e as CustomEvent;
// Detect `PerspectiveSelectDetail` via its distinctive
// prototype getters (`insertFilters` / `removeFilters`).
// The class name is mangled by minification so
// `constructor.name` is unreliable, but the getters are
// preserved on the prototype.
const d = ce.detail as any;
const detailIsSelect = !!(
d &&
typeof d === "object" &&
Array.isArray(d.insertFilters) &&
Array.isArray(d.removeFilters)
);
// Flatten own + getter values into the captured payload
// so it survives the structured-clone boundary.
const detail =
d && typeof d === "object"
? {
...d,
...(detailIsSelect
? {
insertFilters: d.insertFilters,
removeFilters: d.removeFilters,
}
: {}),
}
: d;
events.push({ name, detail, detailIsSelect });
};
for (const name of ["perspective-click", "perspective-global-filter"]) {
v.addEventListener(name, capture(name));
}
(window as any).__capturedEvents = events;
});
}
async function drainCapturedEvents(page: Page): Promise<CapturedEvent[]> {
return page.evaluate(() => {
const events = ((window as any).__capturedEvents ?? []) as any[];
const copy = events.slice();
events.length = 0;
return copy;
});
}
/**
* Resolve the chart plugin's `.webgl-canvas` and return its center
* coordinates in page pixels. Walks the shadow-DOM chain rooted at
* `<perspective-viewer>` since the canvas lives inside the plugin's
* shadow root.
*/
async function getCanvasCenter(
page: Page,
): Promise<{ x: number; y: number; width: number; height: number }> {
return page.evaluate(() => {
const v = document.querySelector("perspective-viewer")!;
const visit = (
root: Element | ShadowRoot,
): HTMLCanvasElement | null => {
if (root instanceof Element) {
const sr = (root as any).shadowRoot as ShadowRoot | null;
if (sr) {
const hit = visit(sr);
if (hit) {
return hit;
}
}
}
const direct = (root as ParentNode).querySelector?.(
".webgl-canvas",
) as HTMLCanvasElement | null;
if (direct) {
return direct;
}
const els = (root as ParentNode).querySelectorAll?.("*") ?? [];
for (const el of Array.from(els)) {
const sr = (el as any).shadowRoot as ShadowRoot | null;
if (sr) {
const hit = visit(sr);
if (hit) {
return hit;
}
}
}
return null;
};
const canvas = visit(v);
if (!canvas) {
throw new Error("webgl-canvas not found in plugin shadow tree");
}
const r = canvas.getBoundingClientRect();
return {
x: r.left + r.width / 2,
y: r.top + r.height / 2,
width: r.width,
height: r.height,
};
});
}
/**
* Click the GL canvas at a fractional position (0..1) from the
* canvas's top-left corner.
*/
async function clickCanvasLower(
page: Page,
fx = 0.25,
fy = 0.7,
): Promise<void> {
const c = await getCanvasCenter(page);
const x = c.x - c.width / 2 + c.width * fx;
const y = c.y - c.height / 2 + c.height * fy;
const baseline = await page.evaluate(
() => ((window as any).__capturedEvents ?? []).length,
);
await page.mouse.click(x, y);
await waitForEvents(page, baseline + 1, 500);
}
/**
* Poll until `window.__capturedEvents` has at least `min` entries
* (or `timeoutMs` elapses).
*/
async function waitForEvents(
page: Page,
min: number,
timeoutMs: number,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const n = await page.evaluate(
() => ((window as any).__capturedEvents ?? []).length,
);
if (n >= min) {
return;
}
await waitOneFrame(page);
}
}
/**
* Restore + install capture in one step. The chart's first render
* dismisses any prior pin (which can fire a stray
* `perspective-global-filter selected:false`); restore *before*
* installing capture so those startup events don't pollute the
* captured array.
*/
async function setupChart(
page: Page,
config: ViewerConfigUpdate,
): Promise<void> {
await restoreChart(page, config);
await installEventCapture(page);
}
test.beforeEach(async ({ page }) => {
await gotoBasic(page);
});
test.describe("viewer-charts user events", () => {
test("Y Bar: click emits perspective-click with row + filter", async ({
page,
}) => {
await setupChart(page, {
plugin: "Y Bar",
group_by: ["Category"],
columns: ["Sales"],
});
await clickCanvasLower(page);
const events = await drainCapturedEvents(page);
const click = events.find((e) => e.name === "perspective-click");
if (!click) {
throw new Error(
`expected perspective-click; got: ${JSON.stringify(events.map((e) => e.name))}`,
);
}
expect(click.detail.column_names).toEqual(["Sales"]);
expect(Array.isArray(click.detail.config?.filter)).toBe(true);
// Filter has at least one clause naming the group_by column.
const cats = click.detail.config.filter.filter(
(f: unknown[]) => f[0] === "Category" && f[1] === "==",
);
expect(cats.length).toBe(1);
expect(typeof cats[0][2]).toBe("string");
expect(typeof click.detail.row).toBe("object");
});
test("Y Bar: click also emits perspective-global-filter selected:true", async ({
page,
}) => {
await setupChart(page, {
plugin: "Y Bar",
group_by: ["Category"],
columns: ["Sales"],
});
await clickCanvasLower(page);
const events = await drainCapturedEvents(page);
const select = events.find(
(e) => e.name === "perspective-global-filter",
);
expect(select).toBeDefined();
expect(select!.detailIsSelect).toBe(true);
expect(select!.detail.selected).toBe(true);
expect(select!.detail.column_names).toEqual(["Sales"]);
expect(select!.detail.insertConfigs).toHaveLength(1);
expect(select!.detail.removeConfigs).toHaveLength(0);
// Helper getters survived our manual copy.
expect(Array.isArray(select!.detail.insertFilters)).toBe(true);
expect(select!.detail.insertFilters.length).toBeGreaterThan(0);
});
test("Y Bar: click + same-target re-click pins then unpins", async ({
page,
}) => {
await setupChart(page, {
plugin: "Y Bar",
group_by: ["Category"],
columns: ["Sales"],
});
await clickCanvasLower(page);
await clickCanvasLower(page);
const events = await drainCapturedEvents(page);
// Expect the second click to land on the same target and
// dismiss the pin — yielding a `selected:false` after the
// initial `selected:true`.
const selects = events.filter(
(e) => e.name === "perspective-global-filter",
);
expect(selects.length).toBeGreaterThanOrEqual(2);
expect(selects[0].detail.selected).toBe(true);
const lastUnselect = selects
.reverse()
.find((e) => e.detail.selected === false);
expect(lastUnselect).toBeDefined();
// The unpin carries the previous insert as `removeConfigs`.
expect(lastUnselect!.detail.removeConfigs.length).toBe(1);
expect(lastUnselect!.detail.insertConfigs).toHaveLength(0);
});
test("Y Bar with split_by: filter carries both group_by and split_by clauses", async ({
page,
}) => {
await setupChart(page, {
plugin: "Y Bar",
group_by: ["Category"],
split_by: ["Region"],
columns: ["Sales"],
});
await clickCanvasLower(page);
const events = await drainCapturedEvents(page);
const click = events.find((e) => e.name === "perspective-click");
if (!click) {
// Some splits may be hidden; if the centerpoint lands in a
// gap the test asserts nothing — the next assertion would
// fire downstream from a real click.
test.skip(true, "center click missed a glyph");
return;
}
const filter = click.detail.config.filter as unknown[][];
const hasCategory = filter.some(
(f) => f[0] === "Category" && f[1] === "==",
);
const hasRegion = filter.some(
(f) => f[0] === "Region" && f[1] === "==",
);
expect(hasCategory).toBe(true);
expect(hasRegion).toBe(true);
});
test("Treemap: leaf click emits click + select with full path filter", async ({
page,
}) => {
await setupChart(page, {
plugin: "Treemap",
group_by: ["Category", "Sub-Category"],
columns: ["Sales"],
});
await clickCanvasLower(page, 0.5, 0.5);
const events = await drainCapturedEvents(page);
const click = events.find((e) => e.name === "perspective-click");
const select = events.find(
(e) => e.name === "perspective-global-filter",
);
expect(click).toBeDefined();
expect(select).toBeDefined();
// Path filter carries one clause per resolved group_by level.
// Treemap may drill to a branch (1 level) or a leaf (2 levels);
// assert at least one is present.
const filter = click!.detail.config.filter as unknown[][];
expect(filter.length).toBeGreaterThanOrEqual(1);
expect(filter[0][0]).toBe("Category");
expect(select!.detail.selected).toBe(true);
});
test("Sunburst: leaf click emits click + select", async ({ page }) => {
await setupChart(page, {
plugin: "Sunburst",
group_by: ["Category", "Sub-Category"],
columns: ["Sales"],
});
// Sunburst's center is the drill-up zone, so a centerpoint
// click would either no-op or fire `selected:false`. Click
// off-center to land on an outer ring (leaf) and assert a
// pin-style event fires.
const c = await getCanvasCenter(page);
// Offset 30% of the smaller dimension into the ring area.
const r = Math.min(c.width, c.height) * 0.3;
await page.mouse.click(c.x + r, c.y);
await waitOneFrame(page);
await waitOneFrame(page);
await waitOneFrame(page);
const events = await drainCapturedEvents(page);
const select = events.find(
(e) => e.name === "perspective-global-filter",
);
expect(select).toBeDefined();
});
test("Heatmap: cell click emits click + select with both axes filtered", async ({
page,
}) => {
await setupChart(page, {
plugin: "Heatmap",
group_by: ["Category"],
split_by: ["Region"],
columns: ["Sales"],
});
await clickCanvasLower(page, 0.5, 0.5);
const events = await drainCapturedEvents(page);
const click = events.find((e) => e.name === "perspective-click");
const select = events.find(
(e) => e.name === "perspective-global-filter",
);
expect(click).toBeDefined();
expect(select).toBeDefined();
expect(click!.detail.column_names).toEqual(["Sales"]);
const filter = click!.detail.config.filter as unknown[][];
// Either filter clause might be missing if the centerpoint
// lands on a rollup row; require at least one.
expect(filter.length).toBeGreaterThanOrEqual(1);
});
test("Candlestick: click on a candle emits click + select", async ({
page,
}) => {
// Use a coarse group_by so each candle spans many CSS pixels.
await setupChart(page, {
plugin: "Candlestick",
group_by: ["Category"],
columns: ["Sales", "Profit", "Discount", "Quantity"],
});
await clickCanvasLower(page);
const events = await drainCapturedEvents(page);
const click = events.find((e) => e.name === "perspective-click");
if (!click) {
test.skip(true, "centerpoint missed wick / body");
return;
}
const filter = click.detail.config.filter as unknown[][];
expect(filter.some((f) => f[0] === "Category")).toBe(true);
});
test("X/Y Scatter: click on a point emits click + select", async ({
page,
}) => {
await setupChart(page, {
plugin: "X/Y Scatter",
columns: ["Sales", "Profit"],
});
await clickCanvasLower(page, 0.5, 0.5);
const events = await drainCapturedEvents(page);
const click = events.find((e) => e.name === "perspective-click");
// Scatter centerpoint may not hit a point in a sparse dataset
// — skip rather than flake.
if (!click) {
test.skip(true, "centerpoint missed a point");
return;
}
expect(click.detail.column_names).toEqual(["Profit"]);
// No group_by → filter is empty (or contains only the split
// prefix if `split_by` was set).
expect(Array.isArray(click.detail.config.filter)).toBe(true);
});
test("X/Y Scatter with split_by: filter carries the split clause", async ({
page,
}) => {
await setupChart(page, {
plugin: "X/Y Scatter",
split_by: ["Region"],
columns: ["Sales", "Profit"],
});
await clickCanvasLower(page, 0.5, 0.5);
const events = await drainCapturedEvents(page);
const click = events.find((e) => e.name === "perspective-click");
if (!click) {
test.skip(true, "centerpoint missed a point");
return;
}
const filter = click.detail.config.filter as unknown[][];
expect(filter.some((f) => f[0] === "Region" && f[1] === "==")).toBe(
true,
);
});
test("Map plugins: no event emitted (scoped out)", async ({ page }) => {
await setupChart(page, {
plugin: "Map Scatter",
columns: ["Postal Code", "Postal Code"],
});
await clickCanvasLower(page, 0.5, 0.5);
const events = await drainCapturedEvents(page);
// Map plugins inherit from CartesianChart so they'll wire
// up — the relevant assertion is "no crash", not silence.
// Document the current behavior: map clicks may emit if the
// underlying CartesianChart resolves a hit. Either is OK.
expect(Array.isArray(events)).toBe(true);
});
test("Detail prototype: select event detail is a PerspectiveSelectDetail instance", async ({
page,
}) => {
await setupChart(page, {
plugin: "Y Bar",
group_by: ["Category"],
columns: ["Sales"],
});
await clickCanvasLower(page);
const events = await drainCapturedEvents(page);
const select = events.find(
(e) => e.name === "perspective-global-filter",
);
expect(select).toBeDefined();
expect(select!.detailIsSelect).toBe(true);
});
test("View change: restore() while pinned emits a selected:false", async ({
page,
}) => {
await setupChart(page, {
plugin: "Y Bar",
group_by: ["Category"],
columns: ["Sales"],
});
await clickCanvasLower(page);
const pre = await drainCapturedEvents(page);
if (!pre.find((e) => e.name === "perspective-global-filter")) {
test.skip(true, "initial click missed a glyph");
return;
}
// Re-restore with a different filter: the view changes, the
// chart's `setView` runs, the pin is dismissed, and we expect
// a `selected:false` to surface.
await page.evaluate(async () => {
const v = document.querySelector("perspective-viewer")! as any;
await v.restore({
plugin: "Y Bar",
group_by: ["Sub-Category"],
columns: ["Sales"],
});
});
await waitOneFrame(page);
await waitOneFrame(page);
await waitOneFrame(page);
const post = await drainCapturedEvents(page);
const unselect = post.find(
(e) =>
e.name === "perspective-global-filter" &&
e.detail.selected === false,
);
expect(unselect).toBeDefined();
});
});