Fantasy-Map-Generator/src/modules/webgl-layer-framework.test.ts
Azgaar a285d450c8 feat: refactor draw-relief-icons renderer to utilize WebGL2LayerFramework
- Removed global renderer, camera, and scene management in favor of layer framework integration.
- Implemented terrain layer registration with setup, render, and dispose methods.
- Enhanced texture loading and caching mechanisms.
- Updated geometry building to return Mesh objects directly.
- Added performance benchmarking story for render performance validation.
- Created bundle size audit story to ensure effective tree-shaking and size constraints.
2026-03-12 15:04:37 +01:00

633 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildCameraBounds,
detectWebGL2,
getLayerZIndex,
WebGL2LayerFrameworkClass,
} from "./webgl-layer-framework";
// Three.js constructors are mocked so that Node-env init() tests work without
// a real WebGL context. These stubs only affect class-level tests that call
// init(); Story 1.1 pure-function tests never invoke Three.js constructors.
vi.mock("three", () => {
// Must use regular `function` (not arrow) so vi.fn() can be called with `new`.
const Group = vi.fn().mockImplementation(function (this: any) {
this.renderOrder = 0;
this.visible = true;
this.clear = vi.fn();
});
const WebGLRenderer = vi.fn().mockImplementation(function (this: any) {
this.setSize = vi.fn();
this.render = vi.fn();
});
const Scene = vi.fn().mockImplementation(function (this: any) {
this.add = vi.fn();
});
const OrthographicCamera = vi.fn().mockImplementation(function (this: any) {
this.left = 0;
this.right = 960;
this.top = 0;
this.bottom = 540;
});
return { Group, WebGLRenderer, Scene, OrthographicCamera };
});
// ─── buildCameraBounds ───────────────────────────────────────────────────────
describe("buildCameraBounds", () => {
it("returns correct bounds for identity transform (viewX=0, viewY=0, scale=1)", () => {
const b = buildCameraBounds(0, 0, 1, 960, 540);
expect(b.left).toBe(0);
expect(b.right).toBe(960);
expect(b.top).toBe(0);
expect(b.bottom).toBe(540);
});
it("top < bottom (Y-down convention matches SVG coordinate space)", () => {
const b = buildCameraBounds(0, 0, 1, 960, 540);
expect(b.top).toBeLessThan(b.bottom);
});
it("returns correct bounds at 2× zoom (viewport shows half the map area)", () => {
const b = buildCameraBounds(0, 0, 2, 960, 540);
expect(b.right).toBe(480);
expect(b.bottom).toBe(270);
});
it("returns correct bounds with pan offset — viewX=-100 pans right, viewY=-50 pans down", () => {
const b = buildCameraBounds(-100, -50, 1, 960, 540);
expect(b.left).toBe(100); // -(-100) / 1
expect(b.right).toBe(1060); // (960 - (-100)) / 1
expect(b.top).toBe(50); // -(-50) / 1
});
it("handles extreme zoom values without NaN or Infinity", () => {
const lo = buildCameraBounds(0, 0, 0.1, 960, 540);
const hi = buildCameraBounds(0, 0, 50, 960, 540);
expect(Number.isFinite(lo.left)).toBe(true);
expect(Number.isFinite(lo.right)).toBe(true);
expect(Number.isFinite(lo.top)).toBe(true);
expect(Number.isFinite(lo.bottom)).toBe(true);
expect(Number.isFinite(hi.left)).toBe(true);
expect(Number.isFinite(hi.right)).toBe(true);
expect(Number.isFinite(hi.top)).toBe(true);
expect(Number.isFinite(hi.bottom)).toBe(true);
});
});
// ─── detectWebGL2 ─────────────────────────────────────────────────────────────
describe("detectWebGL2", () => {
it("returns false when canvas.getContext('webgl2') returns null", () => {
const mockCanvas = {
getContext: () => null,
} as unknown as HTMLCanvasElement;
expect(detectWebGL2(mockCanvas)).toBe(false);
});
it("returns true when canvas.getContext('webgl2') returns a context object", () => {
const mockCtx = { getExtension: () => null };
const mockCanvas = {
getContext: () => mockCtx,
} as unknown as HTMLCanvasElement;
expect(detectWebGL2(mockCanvas)).toBe(true);
});
it("calls loseContext() on the WEBGL_lose_context extension to release probe context", () => {
const loseContext = vi.fn();
const mockExt = { loseContext };
const mockCtx = { getExtension: () => mockExt };
const mockCanvas = {
getContext: () => mockCtx,
} as unknown as HTMLCanvasElement;
detectWebGL2(mockCanvas);
expect(loseContext).toHaveBeenCalledOnce();
});
});
// ─── getLayerZIndex ───────────────────────────────────────────────────────────
describe("getLayerZIndex", () => {
it("returns fallback z-index 2 when element is not found in the DOM", () => {
// In Node.js test environment, document is undefined → fallback 2.
// In jsdom environment, getElementById("nonexistent") returns null → also fallback 2.
expect(getLayerZIndex("nonexistent-layer-id")).toBe(2);
});
});
// ─── WebGL2LayerFrameworkClass ────────────────────────────────────────────────
describe("WebGL2LayerFrameworkClass", () => {
let framework: WebGL2LayerFrameworkClass;
beforeEach(() => {
framework = new WebGL2LayerFrameworkClass();
});
it("hasFallback is false by default (backing field _fallback initialised to false)", () => {
expect(framework.hasFallback).toBe(false);
});
it("register() before init() queues the config in pendingConfigs", () => {
const config = {
id: "test",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
framework.register(config);
expect((framework as any).pendingConfigs).toHaveLength(1);
expect((framework as any).pendingConfigs[0]).toBe(config);
});
it("register() queues multiple configs without throwing", () => {
const makeConfig = (id: string) => ({
id,
anchorLayerId: id,
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
});
framework.register(makeConfig("a"));
framework.register(makeConfig("b"));
expect((framework as any).pendingConfigs).toHaveLength(2);
});
it("setVisible() does not call config.dispose() (GPU state preserved, NFR-P6)", () => {
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
(framework as any).layers.set("terrain", {
config,
group: { visible: true },
});
(framework as any).canvas = { style: { display: "block" } };
framework.setVisible("terrain", false);
expect(config.dispose).not.toHaveBeenCalled();
});
it("requestRender() does not throw when called multiple times", () => {
vi.stubGlobal("requestAnimationFrame", vi.fn().mockReturnValue(0));
expect(() => {
framework.requestRender();
framework.requestRender();
framework.requestRender();
}).not.toThrow();
vi.unstubAllGlobals();
});
it("clearLayer() does not throw and preserves layer registration in the Map", () => {
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
(framework as any).layers.set("terrain", {
config,
group: { visible: true, clear: vi.fn() },
});
framework.clearLayer("terrain");
// Layer registration remains in the Map — only geometry is wiped in the full implementation
expect((framework as any).layers.has("terrain")).toBe(true);
});
it("constructor performs no side effects — all state fields initialised to null/empty", () => {
expect((framework as any).renderer).toBeNull();
expect((framework as any).scene).toBeNull();
expect((framework as any).camera).toBeNull();
expect((framework as any).canvas).toBeNull();
expect((framework as any).container).toBeNull();
expect((framework as any).resizeObserver).toBeNull();
expect((framework as any).rafId).toBeNull();
expect((framework as any).layers.size).toBe(0);
expect((framework as any).pendingConfigs).toHaveLength(0);
});
});
// ─── WebGL2LayerFrameworkClass — init() (Story 1.2) ──────────────────────────
describe("WebGL2LayerFrameworkClass — init()", () => {
let framework: WebGL2LayerFrameworkClass;
// Build a minimal document stub. The canvas mock satisfies both detectWebGL2()
// (probe getContext call) and the DOM canvas element requirements (id/style/etc.).
function buildDocumentMock({ webgl2 = true }: { webgl2?: boolean } = {}) {
const mockCtx = webgl2
? { getExtension: () => ({ loseContext: vi.fn() }) }
: null;
const mockCanvas = {
getContext: (type: string) => (type === "webgl2" ? mockCtx : null),
id: "",
width: 0,
height: 0,
style: { position: "", inset: "", pointerEvents: "", zIndex: "" },
setAttribute: vi.fn(),
};
const mockContainer = {
id: "",
style: { position: "", zIndex: "" },
appendChild: vi.fn(),
clientWidth: 960,
clientHeight: 540,
};
const mockMapEl = {
parentElement: { insertBefore: vi.fn() },
};
return {
createElement: vi.fn((tag: string) =>
tag === "canvas" ? mockCanvas : mockContainer,
),
getElementById: vi.fn((id: string) => (id === "map" ? mockMapEl : null)),
_mocks: { mockCanvas, mockContainer, mockMapEl },
};
}
beforeEach(() => {
framework = new WebGL2LayerFrameworkClass();
// ResizeObserver is not available in Node; stub it so observeResize() doesn't throw.
vi.stubGlobal(
"ResizeObserver",
vi.fn().mockImplementation(function (this: any) {
this.observe = vi.fn();
this.disconnect = vi.fn();
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns false and sets hasFallback when WebGL2 is unavailable (AC2)", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: false }));
const result = framework.init();
expect(result).toBe(false);
expect(framework.hasFallback).toBe(true);
});
it("returns false when #map element is missing — renderer remains null (AC2 guard)", () => {
const doc = buildDocumentMock({ webgl2: true });
doc.getElementById = vi.fn(() => null);
vi.stubGlobal("document", doc);
const result = framework.init();
expect(result).toBe(false);
expect((framework as any).renderer).toBeNull();
});
it("returns true and assigns renderer, scene, camera, canvas on success (AC4)", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: true }));
const result = framework.init();
expect(result).toBe(true);
expect((framework as any).renderer).not.toBeNull();
expect((framework as any).scene).not.toBeNull();
expect((framework as any).camera).not.toBeNull();
expect((framework as any).canvas).not.toBeNull();
});
it("processes pendingConfigs on init() — setup() called once, layer stored, queue flushed", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: true }));
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
framework.register(config);
expect((framework as any).pendingConfigs).toHaveLength(1);
framework.init();
expect(config.setup).toHaveBeenCalledOnce();
expect((framework as any).layers.has("terrain")).toBe(true);
expect((framework as any).pendingConfigs).toHaveLength(0);
});
it("attaches ResizeObserver to container on success (AC5)", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: true }));
framework.init();
expect((framework as any).resizeObserver).not.toBeNull();
});
});
// ─── WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3) ───────────
describe("WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3)", () => {
let framework: WebGL2LayerFrameworkClass;
const makeConfig = (id = "terrain") => ({
id,
anchorLayerId: id,
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
});
beforeEach(() => {
framework = new WebGL2LayerFrameworkClass();
vi.stubGlobal("requestAnimationFrame", vi.fn().mockReturnValue(42));
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// ── requestRender() / RAF coalescing ──────────────────────────────────────
it("requestRender() schedules exactly one RAF for three rapid calls (AC6)", () => {
framework.requestRender();
framework.requestRender();
framework.requestRender();
expect((globalThis as any).requestAnimationFrame).toHaveBeenCalledTimes(1);
});
it("requestRender() resets rafId to null after the frame callback executes (AC6)", () => {
let storedCallback: (() => void) | null = null;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn().mockImplementation((cb: () => void) => {
storedCallback = cb;
return 42;
}),
);
framework.requestRender();
expect((framework as any).rafId).not.toBeNull();
storedCallback!();
expect((framework as any).rafId).toBeNull();
});
// ── syncTransform() ───────────────────────────────────────────────────────
it("syncTransform() applies buildCameraBounds(0,0,1,960,540) to camera (AC8)", () => {
const mockCamera = {
left: 0,
right: 0,
top: 0,
bottom: 0,
updateProjectionMatrix: vi.fn(),
};
(framework as any).camera = mockCamera;
vi.stubGlobal("viewX", 0);
vi.stubGlobal("viewY", 0);
vi.stubGlobal("scale", 1);
vi.stubGlobal("graphWidth", 960);
vi.stubGlobal("graphHeight", 540);
framework.syncTransform();
const expected = buildCameraBounds(0, 0, 1, 960, 540);
expect(mockCamera.left).toBe(expected.left);
expect(mockCamera.right).toBe(expected.right);
expect(mockCamera.top).toBe(expected.top);
expect(mockCamera.bottom).toBe(expected.bottom);
expect(mockCamera.updateProjectionMatrix).toHaveBeenCalledOnce();
});
it("syncTransform() uses ?? defaults when globals are absent (AC8)", () => {
const mockCamera = {
left: 99,
right: 99,
top: 99,
bottom: 99,
updateProjectionMatrix: vi.fn(),
};
(framework as any).camera = mockCamera;
// No globals stubbed — ?? fallbacks (0, 0, 1, 960, 540) take effect
framework.syncTransform();
const expected = buildCameraBounds(0, 0, 1, 960, 540);
expect(mockCamera.left).toBe(expected.left);
expect(mockCamera.right).toBe(expected.right);
});
// ── render() — dispatch order ─────────────────────────────────────────────
it("render() calls syncTransform, then per-layer render, then renderer.render in order (AC7)", () => {
const order: string[] = [];
const layerRenderFn = vi.fn(() => order.push("layer.render"));
const mockRenderer = { render: vi.fn(() => order.push("renderer.render")) };
const mockCamera = {
left: 0,
right: 0,
top: 0,
bottom: 0,
updateProjectionMatrix: vi.fn(),
};
(framework as any).renderer = mockRenderer;
(framework as any).scene = {};
(framework as any).camera = mockCamera;
(framework as any).layers.set("terrain", {
config: { ...makeConfig(), render: layerRenderFn },
group: { visible: true },
});
const syncSpy = vi
.spyOn(framework as any, "syncTransform")
.mockImplementation(() => order.push("syncTransform"));
vi.stubGlobal(
"requestAnimationFrame",
vi.fn().mockImplementation((cb: () => void) => {
cb();
return 1;
}),
);
framework.requestRender();
expect(order).toEqual(["syncTransform", "layer.render", "renderer.render"]);
syncSpy.mockRestore();
});
it("render() skips invisible layers — config.render not called (AC7)", () => {
const invisibleRenderFn = vi.fn();
const mockRenderer = { render: vi.fn() };
(framework as any).renderer = mockRenderer;
(framework as any).scene = {};
(framework as any).camera = {
left: 0,
right: 0,
top: 0,
bottom: 0,
updateProjectionMatrix: vi.fn(),
};
(framework as any).layers.set("terrain", {
config: { ...makeConfig(), render: invisibleRenderFn },
group: { visible: false },
});
vi.stubGlobal(
"requestAnimationFrame",
vi.fn().mockImplementation((cb: () => void) => {
cb();
return 1;
}),
);
framework.requestRender();
expect(invisibleRenderFn).not.toHaveBeenCalled();
});
// ── setVisible() ──────────────────────────────────────────────────────────
it("setVisible(false) sets group.visible=false without calling dispose (AC3, NFR-P6)", () => {
const config = makeConfig();
const group = { visible: true };
(framework as any).layers.set("terrain", { config, group });
(framework as any).canvas = { style: { display: "block" } };
framework.setVisible("terrain", false);
expect(group.visible).toBe(false);
expect(config.dispose).not.toHaveBeenCalled();
});
it("setVisible(false) hides canvas when all layers become invisible (AC3)", () => {
const canvas = { style: { display: "block" } };
(framework as any).canvas = canvas;
(framework as any).layers.set("terrain", {
config: makeConfig(),
group: { visible: true },
});
(framework as any).layers.set("rivers", {
config: makeConfig("rivers"),
group: { visible: false },
});
framework.setVisible("terrain", false);
expect(canvas.style.display).toBe("none");
});
it("setVisible(true) calls requestRender() (AC4)", () => {
const group = { visible: false };
(framework as any).layers.set("terrain", { config: makeConfig(), group });
(framework as any).canvas = { style: { display: "none" } };
const renderSpy = vi.spyOn(framework, "requestRender");
framework.setVisible("terrain", true);
expect(group.visible).toBe(true);
expect(renderSpy).toHaveBeenCalledOnce();
});
// ── clearLayer() ──────────────────────────────────────────────────────────
it("clearLayer() calls group.clear() and preserves layer in the Map (AC5)", () => {
const clearFn = vi.fn();
(framework as any).layers.set("terrain", {
config: makeConfig(),
group: { visible: true, clear: clearFn },
});
framework.clearLayer("terrain");
expect(clearFn).toHaveBeenCalledOnce();
expect((framework as any).layers.has("terrain")).toBe(true);
});
it("clearLayer() does not call renderer.dispose (AC5, NFR-P6)", () => {
const mockRenderer = { render: vi.fn(), dispose: vi.fn() };
(framework as any).renderer = mockRenderer;
(framework as any).layers.set("terrain", {
config: makeConfig(),
group: { visible: true, clear: vi.fn() },
});
framework.clearLayer("terrain");
expect(mockRenderer.dispose).not.toHaveBeenCalled();
});
// ── unregister() ──────────────────────────────────────────────────────────
it("unregister() calls dispose, removes from scene and Map (AC9)", () => {
const config = makeConfig();
const group = { visible: true };
const mockScene = { remove: vi.fn() };
(framework as any).scene = mockScene;
(framework as any).canvas = { style: { display: "block" } };
(framework as any).layers.set("terrain", { config, group });
framework.unregister("terrain");
expect(config.dispose).toHaveBeenCalledWith(group);
expect(mockScene.remove).toHaveBeenCalledWith(group);
expect((framework as any).layers.has("terrain")).toBe(false);
});
it("unregister() hides canvas when it was the last registered layer (AC9)", () => {
const canvas = { style: { display: "block" } };
(framework as any).canvas = canvas;
(framework as any).scene = { remove: vi.fn() };
(framework as any).layers.set("terrain", {
config: makeConfig(),
group: { visible: true },
});
framework.unregister("terrain");
expect(canvas.style.display).toBe("none");
});
});
// ─── WebGL2LayerFramework fallback no-op path (Story 2.3) ───────────────────
describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)", () => {
let framework: WebGL2LayerFrameworkClass;
const makeConfig = () => ({
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 2,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
});
beforeEach(() => {
framework = new WebGL2LayerFrameworkClass();
(framework as any)._fallback = true;
});
it("hasFallback getter returns true when _fallback is set", () => {
expect(framework.hasFallback).toBe(true);
});
it("register() queues config but does not call setup() when fallback is active", () => {
// When _fallback=true, scene is null (init() exits early without creating scene).
// register() therefore queues into pendingConfigs[] — setup() is never called.
const config = makeConfig();
expect(() => framework.register(config)).not.toThrow();
expect(config.setup).not.toHaveBeenCalled();
});
it("setVisible() is a no-op when fallback is active — no exception for false", () => {
expect(() => framework.setVisible("terrain", false)).not.toThrow();
});
it("setVisible() is a no-op when fallback is active — no exception for true", () => {
expect(() => framework.setVisible("terrain", true)).not.toThrow();
});
it("clearLayer() is a no-op when fallback is active", () => {
expect(() => framework.clearLayer("terrain")).not.toThrow();
});
it("requestRender() is a no-op when fallback is active — RAF not scheduled", () => {
const rafMock = vi.fn().mockReturnValue(1);
vi.stubGlobal("requestAnimationFrame", rafMock);
expect(() => framework.requestRender()).not.toThrow();
expect(rafMock).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("unregister() is a no-op when fallback is active", () => {
expect(() => framework.unregister("terrain")).not.toThrow();
});
it("syncTransform() is a no-op when fallback is active", () => {
expect(() => framework.syncTransform()).not.toThrow();
});
it("NFR-C1: no console.error emitted during fallback operations", () => {
const errorSpy = vi.spyOn(console, "error");
framework.register(makeConfig());
framework.setVisible("terrain", false);
framework.clearLayer("terrain");
framework.requestRender();
framework.unregister("terrain");
framework.syncTransform();
expect(errorSpy).not.toHaveBeenCalled();
vi.restoreAllMocks();
});
});