Refactor code structure for improved readability and maintainability

This commit is contained in:
Azgaar 2026-03-12 14:14:29 +01:00
parent 769ef9eff0
commit 8c78fe2ec1
19 changed files with 2661 additions and 47 deletions

View file

@ -175,11 +175,13 @@ describe("WebGL2LayerFrameworkClass", () => {
});
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", () => {
@ -317,3 +319,243 @@ describe("WebGL2LayerFrameworkClass — 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");
});
});

View file

@ -88,13 +88,11 @@ interface RegisteredLayer {
export class WebGL2LayerFrameworkClass {
private canvas: HTMLCanvasElement | null = null;
private renderer: WebGLRenderer | null = null;
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: assigned in init(); read in Story 1.3 render() + syncTransform()
private camera: OrthographicCamera | null = null;
private scene: Scene | null = null;
private layers: Map<string, RegisteredLayer> = new Map();
private pendingConfigs: WebGLLayerConfig[] = []; // queue for register() before init()
private resizeObserver: ResizeObserver | null = null;
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: read/written in Story 1.3 requestRender()
private rafId: number | null = null;
private container: HTMLElement | null = null;
@ -190,25 +188,64 @@ export class WebGL2LayerFrameworkClass {
this.layers.set(config.id, { config, group });
}
unregister(_id: string): void {
// Story 1.3: call config.dispose(group); remove from layers Map; cleanup canvas if empty.
unregister(id: string): void {
if (this._fallback) return;
const layer = this.layers.get(id);
if (!layer || !this.scene) return;
const scene = this.scene;
layer.config.dispose(layer.group);
scene.remove(layer.group);
this.layers.delete(id);
const anyVisible = [...this.layers.values()].some((l) => l.group.visible);
if (this.canvas && !anyVisible) this.canvas.style.display = "none";
}
setVisible(_id: string, _visible: boolean): void {
// Story 1.3: toggle group.visible; hide canvas only when ALL layers invisible (NFR-P6).
setVisible(id: string, visible: boolean): void {
if (this._fallback) return;
const layer = this.layers.get(id);
if (!layer) return;
layer.group.visible = visible;
const anyVisible = [...this.layers.values()].some((l) => l.group.visible);
if (this.canvas) this.canvas.style.display = anyVisible ? "block" : "none";
if (visible) this.requestRender();
}
clearLayer(_id: string): void {
// Story 1.3: group.clear() — wipes Mesh children without disposing renderer (NFR-P6).
clearLayer(id: string): void {
if (this._fallback) return;
const layer = this.layers.get(id);
if (!layer) return;
layer.group.clear();
}
requestRender(): void {
// Story 1.3: RAF-coalesced render request; schedules this.render() via requestAnimationFrame.
this.render();
if (this._fallback) return;
if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.render();
});
}
syncTransform(): void {
// Story 1.3: read window globals viewX/viewY/scale; apply buildCameraBounds to camera.
if (this._fallback || !this.camera) return;
const camera = this.camera;
const viewX = (globalThis as any).viewX ?? 0;
const viewY = (globalThis as any).viewY ?? 0;
const scale = (globalThis as any).scale ?? 1;
const graphWidth = (globalThis as any).graphWidth ?? 960;
const graphHeight = (globalThis as any).graphHeight ?? 540;
const bounds = buildCameraBounds(
viewX,
viewY,
scale,
graphWidth,
graphHeight,
);
camera.left = bounds.left;
camera.right = bounds.right;
camera.top = bounds.top;
camera.bottom = bounds.bottom;
camera.updateProjectionMatrix();
}
// ─── Private helpers ───────────────────────────────────────────────────────
@ -232,7 +269,17 @@ export class WebGL2LayerFrameworkClass {
}
private render(): void {
// Story 1.3: syncTransform → per-layer render(group) callbacks → renderer.render(scene, camera).
if (this._fallback || !this.renderer || !this.scene || !this.camera) return;
const renderer = this.renderer;
const scene = this.scene;
const camera = this.camera;
this.syncTransform();
for (const layer of this.layers.values()) {
if (layer.group.visible) {
layer.config.render(layer.group);
}
}
renderer.render(scene, camera);
}
}