/* Copyright 2026 Marimo. All rights reserved. */ import { afterEach, describe, expect, it, vi } from "vitest"; import { type Data, MatplotlibRenderer, type MatplotlibState, visibleForTesting, } from "../matplotlib-renderer"; const { pixelToData, dataToPixel, pointInPolygon, clampToAxes, isPointInBox, isInAxes, } = visibleForTesting; // A simple axes geometry for testing: // axes occupy pixels [100, 50] to [500, 350] (400px wide, 300px tall) // data range x: [0, 10], y: [0, 100] const LINEAR_AXES: Pick< Data, "axesPixelBounds" | "xBounds" | "xScale" | "yBounds" | "yScale" > = { axesPixelBounds: [100, 50, 500, 350], xBounds: [0, 10], yBounds: [0, 100], xScale: "linear", yScale: "linear", }; const LOG_AXES: Pick< Data, "axesPixelBounds" | "xBounds" | "xScale" | "yBounds" | "yScale" > = { axesPixelBounds: [100, 50, 500, 350], xBounds: [1, 1000], yBounds: [1, 1000], xScale: "log", yScale: "log", }; describe("pixelToData", () => { it("maps top-left of axes to (xMin, yMax)", () => { const result = pixelToData({ x: 100, y: 50 }, LINEAR_AXES); expect(result.x).toBeCloseTo(0); expect(result.y).toBeCloseTo(100); }); it("maps bottom-right of axes to (xMax, yMin)", () => { const result = pixelToData({ x: 500, y: 350 }, LINEAR_AXES); expect(result.x).toBeCloseTo(10); expect(result.y).toBeCloseTo(0); }); it("maps center of axes to midpoint of data range", () => { const result = pixelToData({ x: 300, y: 200 }, LINEAR_AXES); expect(result.x).toBeCloseTo(5); expect(result.y).toBeCloseTo(50); }); }); describe("dataToPixel", () => { it("maps (xMin, yMax) to top-left of axes", () => { const result = dataToPixel({ x: 0, y: 100 }, LINEAR_AXES); expect(result.x).toBeCloseTo(100); expect(result.y).toBeCloseTo(50); }); it("maps (xMax, yMin) to bottom-right of axes", () => { const result = dataToPixel({ x: 10, y: 0 }, LINEAR_AXES); expect(result.x).toBeCloseTo(500); expect(result.y).toBeCloseTo(350); }); }); describe("pixelToData / dataToPixel roundtrip", () => { it("roundtrips linear coordinates", () => { const pixel = { x: 250, y: 175 }; const data = pixelToData(pixel, LINEAR_AXES); const back = dataToPixel(data, LINEAR_AXES); expect(back.x).toBeCloseTo(pixel.x); expect(back.y).toBeCloseTo(pixel.y); }); it("roundtrips log coordinates", () => { const pixel = { x: 300, y: 200 }; const data = pixelToData(pixel, LOG_AXES); const back = dataToPixel(data, LOG_AXES); expect(back.x).toBeCloseTo(pixel.x); expect(back.y).toBeCloseTo(pixel.y); }); }); describe("pointInPolygon", () => { // Triangle with vertices at (0,0), (10,0), (5,10) const triangle = [ { x: 0, y: 0 }, { x: 10, y: 0 }, { x: 5, y: 10 }, ]; it("returns true for a point inside", () => { expect(pointInPolygon({ x: 5, y: 3 }, triangle)).toBe(true); }); it("returns false for a point outside", () => { expect(pointInPolygon({ x: 20, y: 20 }, triangle)).toBe(false); }); it("returns false for a point just outside a side", () => { expect(pointInPolygon({ x: 0, y: 5 }, triangle)).toBe(false); }); }); describe("clampToAxes", () => { it("does not change a point inside the axes", () => { const result = clampToAxes({ x: 300, y: 200 }, LINEAR_AXES); expect(result).toEqual({ x: 300, y: 200 }); }); it("clamps a point left of axes to left edge", () => { const result = clampToAxes({ x: 50, y: 200 }, LINEAR_AXES); expect(result.x).toBe(100); expect(result.y).toBe(200); }); it("clamps a point above axes to top edge", () => { const result = clampToAxes({ x: 300, y: 10 }, LINEAR_AXES); expect(result.x).toBe(300); expect(result.y).toBe(50); }); it("clamps a point beyond bottom-right to corner", () => { const result = clampToAxes({ x: 600, y: 400 }, LINEAR_AXES); expect(result).toEqual({ x: 500, y: 350 }); }); }); describe("isPointInBox", () => { const boxStart = { x: 10, y: 10 }; const boxEnd = { x: 50, y: 50 }; it("returns true for a point inside", () => { expect(isPointInBox({ x: 30, y: 30 }, boxStart, boxEnd)).toBe(true); }); it("returns true for a point on the edge", () => { expect(isPointInBox({ x: 10, y: 30 }, boxStart, boxEnd)).toBe(true); }); it("returns false for a point outside", () => { expect(isPointInBox({ x: 5, y: 30 }, boxStart, boxEnd)).toBe(false); }); it("works regardless of start/end order", () => { // Swap start and end (end is top-left, start is bottom-right) expect(isPointInBox({ x: 30, y: 30 }, boxEnd, boxStart)).toBe(true); expect(isPointInBox({ x: 5, y: 30 }, boxEnd, boxStart)).toBe(false); }); }); describe("isInAxes", () => { it("returns true for a point inside the axes", () => { expect(isInAxes({ x: 300, y: 200 }, LINEAR_AXES)).toBe(true); }); it("returns true for a point on the boundary", () => { expect(isInAxes({ x: 100, y: 50 }, LINEAR_AXES)).toBe(true); // top-left expect(isInAxes({ x: 500, y: 350 }, LINEAR_AXES)).toBe(true); // bottom-right expect(isInAxes({ x: 100, y: 350 }, LINEAR_AXES)).toBe(true); // bottom-left expect(isInAxes({ x: 500, y: 50 }, LINEAR_AXES)).toBe(true); // top-right }); it("returns false for a point left of axes", () => { expect(isInAxes({ x: 99, y: 200 }, LINEAR_AXES)).toBe(false); }); it("returns false for a point right of axes", () => { expect(isInAxes({ x: 501, y: 200 }, LINEAR_AXES)).toBe(false); }); it("returns false for a point above axes", () => { expect(isInAxes({ x: 300, y: 49 }, LINEAR_AXES)).toBe(false); }); it("returns false for a point below axes", () => { expect(isInAxes({ x: 300, y: 351 }, LINEAR_AXES)).toBe(false); }); }); // A 100x100 chart whose axes fill the whole figure. function createState(): MatplotlibState { return { ...LINEAR_AXES, axesPixelBounds: [0, 0, 100, 100], yBounds: [0, 10], chartBase64: "first", width: 100, height: 100, selectionColor: "blue", selectionOpacity: 0.15, strokeWidth: 2, debounce: false, value: { has_selection: false }, setValue: vi.fn(), }; } // jsdom has no matchMedia, no animation frames and no canvas context. function stubBrowser(ctx: Partial | null = null) { vi.stubGlobal( "matchMedia", vi.fn(() => ({ addEventListener: vi.fn() })), ); const requestAnimationFrame = vi.fn(() => 1); vi.stubGlobal("requestAnimationFrame", requestAnimationFrame); vi.stubGlobal("cancelAnimationFrame", vi.fn()); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( ctx as CanvasRenderingContext2D | null, ); return { requestAnimationFrame }; } // jsdom does not fetch images; resolve them synchronously instead. function stubSyncImage() { vi.stubGlobal( "Image", class { public onload: (() => void) | null = null; public set src(_value: string) { this.onload?.(); } }, ); } function createRecordingContext() { return { setTransform: vi.fn(), clearRect: vi.fn(), drawImage: vi.fn(), save: vi.fn(), restore: vi.fn(), }; } describe("MatplotlibRenderer", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("restores a new selection when the chart changes", () => { stubBrowser(); const value = { type: "box", has_selection: true, data: { x_min: 2, x_max: 4, y_min: 2, y_max: 4 }, } as const; const state = createState(); const container = document.createElement("div"); const controller = new AbortController(); const renderer = new MatplotlibRenderer(container, { state, signal: controller.signal, }); // Cell reruns reuse the renderer, updating the chart and initial value together. renderer.update({ ...state, chartBase64: "second", value }); const canvas = container.querySelector("canvas"); expect(canvas).not.toBeNull(); if (!canvas) { return; } vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue( DOMRect.fromRect({ width: 100, height: 100 }), ); canvas.dispatchEvent( new MouseEvent("pointermove", { clientX: 30, clientY: 70 }), ); expect(canvas.style.cursor).toBe("move"); controller.abort(); }); // Safari only fires resize on zoom, not the resolution query (#10625). it("re-syncs the backing store on resize, without matchMedia firing", () => { stubBrowser(); vi.stubGlobal("devicePixelRatio", 2); const container = document.createElement("div"); const controller = new AbortController(); new MatplotlibRenderer(container, { state: createState(), signal: controller.signal, }); const canvas = container.querySelector("canvas"); expect(canvas?.width).toBe(200); vi.stubGlobal("devicePixelRatio", 1); window.dispatchEvent(new Event("resize")); expect(canvas?.width).toBe(100); controller.abort(); }); // Re-sizing clears the buffer a frame before the repaint, so an over-eager // guard flickers. 700 * 1.1 is the case a truncating comparison misses. it("leaves the backing store alone when resize fires at an unchanged DPR", () => { const { requestAnimationFrame } = stubBrowser(); vi.stubGlobal("devicePixelRatio", 1.1); const container = document.createElement("div"); const controller = new AbortController(); new MatplotlibRenderer(container, { state: { ...createState(), width: 700 }, signal: controller.signal, }); const canvas = container.querySelector("canvas"); expect(canvas?.width).toBe(770); requestAnimationFrame.mockClear(); window.dispatchEvent(new Event("resize")); expect(canvas?.width).toBe(770); expect(requestAnimationFrame).not.toHaveBeenCalled(); controller.abort(); }); it("draws with the backing-store scale, not the current devicePixelRatio", () => { const ctx = createRecordingContext(); stubBrowser(ctx); stubSyncImage(); vi.stubGlobal("devicePixelRatio", 2); const state = createState(); const container = document.createElement("div"); const controller = new AbortController(); const renderer = new MatplotlibRenderer(container, { state, signal: controller.signal, }); expect(container.querySelector("canvas")?.width).toBe(200); // Zoom with nothing re-syncing the buffer: it still holds the old DPR, so // the redraw must too, or the image lands scaled by dpr_new / dpr_old. vi.stubGlobal("devicePixelRatio", 1); ctx.setTransform.mockClear(); renderer.update({ ...state, strokeWidth: 4 }); expect(ctx.setTransform.mock.calls).toEqual([[2, 0, 0, 2, 0, 0]]); expect(ctx.drawImage).toHaveBeenLastCalledWith( expect.anything(), 0, 0, 100, 100, ); controller.abort(); }); it("clears the whole backing store when a re-run swaps the chart", () => { const ctx = createRecordingContext(); stubBrowser(ctx); vi.stubGlobal("devicePixelRatio", 2); const state = createState(); const container = document.createElement("div"); const controller = new AbortController(); const renderer = new MatplotlibRenderer(container, { state, signal: controller.signal, }); vi.stubGlobal("devicePixelRatio", 1); ctx.setTransform.mockClear(); ctx.clearRect.mockClear(); renderer.update({ ...state, chartBase64: "second" }); // A stale scale here would leave the outer 3/4 of the buffer unwiped. expect(ctx.setTransform.mock.calls).toEqual([[2, 0, 0, 2, 0, 0]]); expect(ctx.clearRect.mock.calls).toEqual([[0, 0, 100, 100]]); controller.abort(); }); it("scales each axis by its own backing-store ratio", () => { const ctx = createRecordingContext(); stubBrowser(ctx); stubSyncImage(); // canvas.width/height are integers, so this truncates the two dimensions // by different fractions: 150 * 1.25 -> 187 (0.5 lost), 500 * 1.25 -> 625 // (exact). One shared, width-derived scale would under-cover the buffer // vertically and leave its bottom rows unwiped. vi.stubGlobal("devicePixelRatio", 1.25); const state = { ...createState(), width: 150, height: 500 }; const container = document.createElement("div"); const controller = new AbortController(); new MatplotlibRenderer(container, { state, signal: controller.signal }); const canvas = container.querySelector("canvas"); expect(canvas?.width).toBe(187); expect(canvas?.height).toBe(625); // The pre-load clear and the draw each set up their own transform. const transform = [187 / 150, 0, 0, 625 / 500, 0, 0]; const rect = [0, 0, 150, 500]; expect(ctx.setTransform.mock.calls).toEqual([transform, transform]); expect(ctx.clearRect.mock.calls).toEqual([rect, rect]); controller.abort(); }); });