Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions e2e/tests/minimap/MiniMapPageObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Page } from "@playwright/test";
import type { GraphPageObject } from "../../page-objects/GraphPageObject";

export interface MiniMapLayerOptions {
width?: number;
height?: number;
classNames?: string[];
cameraBorderSize?: number;
cameraBorderColor?: string;
location?:
| "topLeft"
| "topRight"
| "bottomLeft"
| "bottomRight"
| { top?: string; left?: string; bottom?: string; right?: string };
}

export interface MinimapBounds {
x: number;
y: number;
width: number;
height: number;
}

export interface MinimapPositionRelativeToRoot {
fromLeft: number;
fromTop: number;
fromRight: number;
fromBottom: number;
}

/**
* Component Object Model for the MiniMap layer.
* Provides helpers to add, interact with and query the minimap canvas element.
*/
export class MiniMapPageObject {
constructor(
private readonly page: Page,
private readonly graphPO: GraphPageObject
) {}

/**
* Adds MiniMapLayer to the running graph instance.
* Waits for the initial render to complete.
*/
async addLayer(options: MiniMapLayerOptions = {}): Promise<void> {
await this.page.evaluate((opts) => {
const { MiniMapLayer } = (window as any).GraphModule;
(window as any).minimapLayer = window.graph.addLayer(MiniMapLayer, opts);
}, options as Record<string, unknown>);

await this.graphPO.waitForFrames(5);
}

/**
* Returns true if the minimap canvas element is present in the DOM.
*/
async exists(): Promise<boolean> {
return this.page.evaluate(() => Boolean(document.querySelector(".graph-minimap")));
}

/**
* Returns the minimap canvas bounding rect in viewport coordinates.
*/
async getCanvasBounds(): Promise<MinimapBounds> {
return this.page.evaluate(() => {
const canvas = document.querySelector(".graph-minimap") as HTMLCanvasElement;
if (!canvas) throw new Error("Minimap canvas not found");
const rect = canvas.getBoundingClientRect();
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
});
}

/**
* Returns the minimap canvas position relative to the #root element.
* Useful for verifying location props (topLeft, topRight, etc.).
*/
async getPositionRelativeToRoot(): Promise<MinimapPositionRelativeToRoot> {
return this.page.evaluate(() => {
const canvas = document.querySelector(".graph-minimap") as HTMLCanvasElement;
const root = document.getElementById("root");
if (!canvas || !root) throw new Error("Canvas or root not found");

const canvasRect = canvas.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();

return {
fromLeft: canvasRect.left - rootRect.left,
fromTop: canvasRect.top - rootRect.top,
fromRight: rootRect.right - canvasRect.right,
fromBottom: rootRect.bottom - canvasRect.bottom,
};
});
}

/**
* Returns the rendered CSS size of the minimap canvas (in layout pixels).
*/
async getCanvasSize(): Promise<{ width: number; height: number }> {
return this.page.evaluate(() => {
const canvas = document.querySelector(".graph-minimap") as HTMLCanvasElement;
if (!canvas) throw new Error("Minimap canvas not found");
const rect = canvas.getBoundingClientRect();
return { width: Math.round(rect.width), height: Math.round(rect.height) };
});
}

/**
* Clicks at a relative position on the minimap canvas.
* @param relativeX - 0 = left edge, 1 = right edge
* @param relativeY - 0 = top edge, 1 = bottom edge
*/
async clickAt(relativeX: number, relativeY: number): Promise<void> {
const bounds = await this.getCanvasBounds();
await this.page.mouse.click(
bounds.x + bounds.width * relativeX,
bounds.y + bounds.height * relativeY
);
await this.graphPO.waitForFrames(3);
}

/**
* Performs a drag gesture within the minimap canvas.
* Both start and end coordinates are relative (0–1).
*/
async dragFrom(
fromRelX: number,
fromRelY: number,
toRelX: number,
toRelY: number
): Promise<void> {
const bounds = await this.getCanvasBounds();
const fromX = bounds.x + bounds.width * fromRelX;
const fromY = bounds.y + bounds.height * fromRelY;
const toX = bounds.x + bounds.width * toRelX;
const toY = bounds.y + bounds.height * toRelY;

await this.page.mouse.move(fromX, fromY);
await this.page.mouse.down();
await this.graphPO.waitForFrames(2);

await this.page.mouse.move(toX, toY, { steps: 10 });
await this.graphPO.waitForFrames(3);

await this.page.mouse.up();
await this.graphPO.waitForFrames(3);
}

/**
* Checks whether the minimap canvas element has the given CSS class.
*/
async hasClass(className: string): Promise<boolean> {
return this.page.evaluate((cls) => {
const canvas = document.querySelector(".graph-minimap");
return canvas ? canvas.classList.contains(cls) : false;
}, className);
}
}
167 changes: 167 additions & 0 deletions e2e/tests/minimap/minimap-graph-changes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { test, expect } from "@playwright/test";
import { GraphPageObject } from "../../page-objects/GraphPageObject";
import { MiniMapPageObject } from "./MiniMapPageObject";

const INITIAL_BLOCKS = [
{
id: "block-1",
is: "Block" as const,
x: 0,
y: 0,
width: 200,
height: 100,
name: "Block 1",
anchors: [],
selected: false,
},
{
id: "block-2",
is: "Block" as const,
x: 600,
y: 600,
width: 200,
height: 100,
name: "Block 2",
anchors: [],
selected: false,
},
];

/**
* A block placed far outside the initial bounding box.
* Adding it causes the usable rect (and therefore minimap scale) to change,
* which in turn changes the world coordinate that any given minimap pixel
* corresponds to.
*/
const FAR_BLOCK = {
id: "block-far",
is: "Block" as const,
x: 5000,
y: 5000,
width: 200,
height: 100,
name: "Far Block",
anchors: [],
selected: false,
};

test.describe("MiniMap – graph changes reflection", () => {
let graphPO: GraphPageObject;
let minimapPO: MiniMapPageObject;

test.beforeEach(async ({ page }) => {
graphPO = new GraphPageObject(page);
minimapPO = new MiniMapPageObject(page, graphPO);

await graphPO.initialize({ blocks: INITIAL_BLOCKS, connections: [] });
await minimapPO.addLayer({ location: "topLeft" });
});

test("adding a block far away should update minimap coordinate mapping", async () => {
const camera = graphPO.getCamera();

// Click at minimap center with initial blocks; record resulting camera position
await minimapPO.clickAt(0.5, 0.5);
const stateWithInitialBlocks = await camera.getState();

// Add a block far outside the current bounding box –
// this expands the usable rect and forces the minimap to rescale
await graphPO.setEntities({
blocks: [...INITIAL_BLOCKS, FAR_BLOCK],
connections: [],
});
await graphPO.waitForFrames(5);

// Click at the same relative position on the now-rescaled minimap
await minimapPO.clickAt(0.5, 0.5);
const stateAfterFarBlock = await camera.getState();

// The minimap center now corresponds to a different world position because
// the coordinate mapping changed — camera must have moved to a different place
expect(Math.abs(stateAfterFarBlock.x - stateWithInitialBlocks.x)).toBeGreaterThan(10);
expect(Math.abs(stateAfterFarBlock.y - stateWithInitialBlocks.y)).toBeGreaterThan(10);
});

test("removing all blocks should not crash minimap and interaction should still work", async () => {
const camera = graphPO.getCamera();

// Remove all blocks
await graphPO.setEntities({ blocks: [], connections: [] });
await graphPO.waitForFrames(5);

// Minimap element must still be in the DOM
expect(await minimapPO.exists()).toBe(true);

// Clicking minimap should not throw and should change camera position
const stateBefore = await camera.getState();
await minimapPO.clickAt(0.5, 0.5);
const stateAfter = await camera.getState();

// Camera may or may not move (empty graph), but no error should occur
// We just verify the page didn't crash (test would fail with an exception otherwise)
expect(typeof stateAfter.x).toBe("number");
expect(typeof stateAfter.y).toBe("number");
// With no blocks the usable rect has a default extent so camera should move
expect(stateAfter.x).not.toBeNaN();
expect(stateAfter.y).not.toBeNaN();
// Confirm state changed (minimap still routes clicks to camera.move)
const moved = stateAfter.x !== stateBefore.x || stateAfter.y !== stateBefore.y;
expect(moved).toBe(true);
});

test("camera pan should not break minimap click interaction", async () => {
const camera = graphPO.getCamera();

// Pan the camera significantly
await camera.emulatePan(200, 150);

// After pan the minimap's camera-frame border moved, but the coordinate
// mapping (scale / relativeX / relativeY) is unchanged.
// Clicking the minimap must still move the camera.
const stateBefore = await camera.getState();
await minimapPO.clickAt(0.2, 0.2);
const stateAfter = await camera.getState();

expect(stateAfter.x).not.toBe(stateBefore.x);
expect(stateAfter.y).not.toBe(stateBefore.y);
});

test("camera zoom should not break minimap click interaction", async () => {
const camera = graphPO.getCamera();

// Zoom in
await camera.emulateZoom(-300);

const stateBefore = await camera.getState();
await minimapPO.clickAt(0.8, 0.8);
const stateAfter = await camera.getState();

expect(stateAfter.x).not.toBe(stateBefore.x);
expect(stateAfter.y).not.toBe(stateBefore.y);
});

test("moving a block should update minimap coordinate mapping", async () => {
const camera = graphPO.getCamera();

// Baseline: click center of minimap
await minimapPO.clickAt(0.5, 0.5);
const stateBaseline = await camera.getState();

// Move block-2 far away, forcing usable rect to expand
await graphPO.setEntities({
blocks: [
INITIAL_BLOCKS[0],
{ ...INITIAL_BLOCKS[1], x: 4000, y: 4000 },
],
connections: [],
});
await graphPO.waitForFrames(5);

// Click minimap center again – scale changed, so camera ends up elsewhere
await minimapPO.clickAt(0.5, 0.5);
const stateAfterMove = await camera.getState();

expect(Math.abs(stateAfterMove.x - stateBaseline.x)).toBeGreaterThan(10);
expect(Math.abs(stateAfterMove.y - stateBaseline.y)).toBeGreaterThan(10);
});
});
Loading
Loading