mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-22 15:17:23 +01:00
- 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.
633 lines
23 KiB
TypeScript
633 lines
23 KiB
TypeScript
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();
|
||
});
|
||
});
|