Skip to content

Commit 13323eb

Browse files
committed
test(e2e): cover plot capture, notebook figures and signal ROI drag
1 parent 907a106 commit 13323eb

2 files changed

Lines changed: 225 additions & 0 deletions

File tree

‎tests/e2e/notebooks.spec.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,36 @@ test.describe("Notebook UI", () => {
231231
await expect(rendered.locator("li")).toHaveCount(2);
232232
});
233233

234+
test("show_signal renders an inline Plotly figure", async ({
235+
warmPage: page,
236+
}) => {
237+
const editor = page.locator(".nb-cell-editor .cm-content").first();
238+
await editor.click();
239+
await page.keyboard.type(
240+
[
241+
"x = np.linspace(0, 1, 50)",
242+
'await proxy.add_signal("nb_plotly", x, x ** 2)',
243+
'await show_signal("nb_plotly")',
244+
].join("\n"),
245+
);
246+
await page.keyboard.press("Control+Enter");
247+
248+
const figure = page.locator(".nb-output-plotly .js-plotly-plot").first();
249+
await expect(figure).toBeAttached({ timeout: 60_000 });
250+
// Adding the signal brings the Plot view forward: return to the notebook.
251+
await page.getByRole("tab", { name: "Notebooks" }).click();
252+
await expect(figure).toBeVisible();
253+
await expect(figure.locator(".xtick").first()).toBeVisible();
254+
const trace = await figure.evaluate((el) => {
255+
const gd = el as HTMLElement & {
256+
_fullData?: { type?: string; x?: ArrayLike<number> }[];
257+
};
258+
const first = gd._fullData?.[0];
259+
return { type: first?.type, points: first?.x?.length ?? 0 };
260+
});
261+
expect(trace).toEqual({ type: "scattergl", points: 50 });
262+
});
263+
234264
test("Rename tab title updates the active tab title", async ({
235265
warmPage: page,
236266
}) => {
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Signal and image plot interactions that rely on Plotly behaviour outside
3+
* its TypeScript declarations: PNG capture of the visible plot (the AI
4+
* assistant's ``capture_view`` tool, through ``window.Plotly.toImage``) and
5+
* signal ROI rectangles dragged in the plot, whose new bounds must reach the
6+
* runtime.
7+
*/
8+
import { expect, test, type Page } from "@playwright/test";
9+
10+
import { waitForRuntimeReady } from "./fixtures";
11+
12+
const SIGNAL = "plot_interactions_signal";
13+
const IMAGE = "plot_interactions_image";
14+
15+
interface RuntimeApi {
16+
addSignalFromArrays(params: {
17+
title: string;
18+
xdata: number[];
19+
ydata: number[];
20+
}): Promise<string>;
21+
setSignalRoi(
22+
id: string,
23+
segments: { xmin: number; xmax: number; title?: string }[],
24+
): Promise<unknown>;
25+
getSignalRoi(id: string): Promise<{ xmin: number; xmax: number }[]>;
26+
runPython(code: string): Promise<unknown>;
27+
}
28+
29+
async function selectObject(
30+
page: Page,
31+
tab: "Signals" | "Images",
32+
title: string,
33+
): Promise<void> {
34+
// Runtime-created objects bypass React state: switch tabs to refresh.
35+
await page
36+
.getByRole("tab", { name: tab === "Signals" ? "Images" : "Signals" })
37+
.click();
38+
await page.getByRole("tab", { name: tab }).click();
39+
await page
40+
.locator(".object-tree-item")
41+
.filter({ hasText: title })
42+
.first()
43+
.click({ timeout: 30_000 });
44+
}
45+
46+
/** Capture a panel through the AI assistant's capture module and return the
47+
* PNG size and the number of distinct colours found in it. */
48+
async function capture(
49+
page: Page,
50+
panel: "signal" | "image",
51+
): Promise<{ width: number; height: number; colours: number }> {
52+
return page.evaluate(async (kind) => {
53+
// Same module instance as the app: the dev server serves one URL.
54+
const mod = await import("/src/aiassistant/plotCapture.ts");
55+
const shot = await mod.capturePlotPng({ panel: kind });
56+
const img = new Image();
57+
img.src = shot.dataUrl;
58+
await img.decode();
59+
const canvas = document.createElement("canvas");
60+
canvas.width = img.naturalWidth;
61+
canvas.height = img.naturalHeight;
62+
const ctx = canvas.getContext("2d");
63+
if (!ctx) throw new Error("no 2-D context");
64+
ctx.drawImage(img, 0, 0);
65+
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
66+
const colours = new Set<number>();
67+
for (let i = 0; i < pixels.length && colours.size < 64; i += 4 * 7) {
68+
colours.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
69+
}
70+
return {
71+
width: canvas.width,
72+
height: canvas.height,
73+
colours: colours.size,
74+
};
75+
}, panel);
76+
}
77+
78+
async function openTopMenu(page: Page, key: string): Promise<void> {
79+
const top = page.locator(`[data-menu-top="${key}"]`);
80+
await top.hover();
81+
await page.waitForTimeout(120);
82+
if ((await top.getAttribute("aria-expanded")) !== "true") {
83+
await top.click();
84+
await page.waitForTimeout(120);
85+
}
86+
}
87+
88+
test.describe("Plot interactions beyond Plotly's typings", () => {
89+
test.describe.configure({ mode: "serial" });
90+
test.setTimeout(300_000);
91+
92+
let page: Page;
93+
let signalId: string;
94+
95+
test.beforeAll(async ({ browser }) => {
96+
page = await browser.newPage({ viewport: { width: 1_600, height: 900 } });
97+
await page.goto("/");
98+
await waitForRuntimeReady(page);
99+
signalId = await page.evaluate(async (title) => {
100+
const runtime = (window as unknown as { runtime: RuntimeApi }).runtime;
101+
const xdata = Array.from({ length: 201 }, (_, i) => i / 2);
102+
const ydata = xdata.map((x) => Math.sin(x / 8));
103+
return runtime.addSignalFromArrays({ title, xdata, ydata });
104+
}, SIGNAL);
105+
await page.evaluate(async (title) => {
106+
const runtime = (window as unknown as { runtime: RuntimeApi }).runtime;
107+
await runtime.runPython(
108+
`import numpy as np\n` +
109+
`add_image_from_array(${JSON.stringify(title)}, ` +
110+
`np.add.outer(np.arange(64.0), np.arange(64.0)))`,
111+
);
112+
}, IMAGE);
113+
});
114+
115+
test.afterAll(async () => {
116+
await page.close();
117+
});
118+
119+
test("captures the visible signal and image plots as PNG", async () => {
120+
await selectObject(page, "Signals", SIGNAL);
121+
await expect(
122+
page.locator(".signal-plot-host .scatterlayer .trace .js-line"),
123+
).toBeVisible({ timeout: 30_000 });
124+
const signal = await capture(page, "signal");
125+
expect(signal.width).toBeGreaterThan(100);
126+
expect(signal.height).toBeGreaterThan(100);
127+
expect(signal.colours).toBeGreaterThan(2);
128+
129+
await selectObject(page, "Images", IMAGE);
130+
await expect(
131+
page.locator(".image-plot-host .js-plotly-plot .nsewdrag").first(),
132+
).toBeVisible({ timeout: 30_000 });
133+
const image = await capture(page, "image");
134+
expect(image.width).toBeGreaterThan(100);
135+
// The colormapped gradient must be part of the capture, not only axes.
136+
expect(image.colours).toBeGreaterThan(16);
137+
});
138+
139+
test("a signal ROI dragged in the plot updates the runtime", async () => {
140+
await page.evaluate(async (id) => {
141+
const runtime = (window as unknown as { runtime: RuntimeApi }).runtime;
142+
await runtime.setSignalRoi(id, [{ xmin: 20, xmax: 40, title: "r" }]);
143+
}, signalId);
144+
await selectObject(page, "Images", IMAGE);
145+
await selectObject(page, "Signals", SIGNAL);
146+
await openTopMenu(page, "ROI");
147+
await page
148+
.locator(".menu-dropdown")
149+
.getByRole("menuitem", { name: /Edit regions of interest/i })
150+
.first()
151+
.click();
152+
await expect(page.locator(".roi-floating")).toBeVisible({
153+
timeout: 15_000,
154+
});
155+
// A drag must move the existing ROI, not draw a new one.
156+
const armed = page.locator(".roi-floating button.active[title^='Draw']");
157+
if ((await armed.count()) > 0) await armed.first().click();
158+
159+
const shape = page
160+
.locator(".signal-plot-host .layer-above .shapelayer path")
161+
.first();
162+
await expect(shape).toBeVisible({ timeout: 15_000 });
163+
const plot = await page
164+
.locator(".signal-plot-host .nsewdrag")
165+
.first()
166+
.boundingBox();
167+
const box = await shape.boundingBox();
168+
if (!plot || !box) throw new Error("signal plot or ROI shape not found");
169+
const sx = box.x + box.width / 2;
170+
const sy = box.y + box.height / 2;
171+
await page.mouse.move(sx, sy);
172+
await page.mouse.down();
173+
await page.mouse.move(sx + plot.width * 0.2, sy, { steps: 12 });
174+
await page.mouse.up();
175+
176+
await expect
177+
.poll(
178+
async () =>
179+
page.evaluate(async (id) => {
180+
const runtime = (window as unknown as { runtime: RuntimeApi })
181+
.runtime;
182+
return (await runtime.getSignalRoi(id))[0]?.xmin ?? null;
183+
}, signalId),
184+
{ timeout: 15_000 },
185+
)
186+
.toBeGreaterThan(25);
187+
const [segment] = await page.evaluate(
188+
async (id) =>
189+
(window as unknown as { runtime: RuntimeApi }).runtime.getSignalRoi(id),
190+
signalId,
191+
);
192+
// A move keeps the width: only the position changes.
193+
expect(segment.xmax - segment.xmin).toBeCloseTo(20, 0);
194+
});
195+
});

0 commit comments

Comments
 (0)