Fantasy-Map-Generator/_bmad-output/implementation-artifacts/2-3-webgl2-fallback-integration-verification.md
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

18 KiB
Raw Blame History

Story 2.3: WebGL2 Fallback Integration Verification

Status: review Epic: 2 — Relief Icons Layer Migration Story Key: 2-3-webgl2-fallback-integration-verification Created: 2026-03-12 Developer: unassigned


Story

As a developer, I want the WebGL2 → SVG fallback path end-to-end verified, So that users on browsers without WebGL2 (or with hardware acceleration disabled) see identical map output via the SVG renderer.


Acceptance Criteria

AC1: Framework init with no WebGL2 → hasFallback Given a Vitest test that mocks canvas.getContext('webgl2') to return null When WebGL2LayerFramework.init() is called Then hasFallback === true, init() returns false, and the framework DOM setup (map-container wrapping, canvas insertion) does NOT occur

AC2: All framework methods are no-ops when hasFallback === true Given hasFallback === true When WebGL2LayerFramework.register(), setVisible(), clearLayer(), and requestRender() are called Then all calls are silent no-ops — no exceptions thrown

AC3: drawRelief() routes to SVG when hasFallback === true Given window.drawRelief() is called and hasFallback === true When execution runs Then drawSvgRelief(icons, parentEl) is invoked and SVG nodes are appended to the terrain layer — visually identical to the current implementation (FR19)

AC4: SVG fallback visual parity Given SVG fallback is active When a visually rendered map is compared against the current SVG baseline Then icon positions, sizes, and orientations are pixel-indistinguishable (FR19)

AC5: Fallback tests pass Given the fallback test is added to webgl-layer-framework.test.ts When npx vitest run executes Then the fallback detection test passes (FR26) and all 34+ tests pass


Context

Prerequisites

  • Story 2.2 must be complete. The refactored draw-relief-icons.ts uses WebGL2LayerFramework.hasFallback to route to drawSvg(). The fallback path exists in code; this story verifies it via tests.
  • Stories 1.11.3 complete. Framework tests at 34 passing, 85.13% statement coverage.

What This Story Is

This is a test coverage and verification story. The fallback path already exists in:

  1. detectWebGL2() — exported pure function (already tested in Story 1.1 with 2 tests)
  2. WebGL2LayerFrameworkClass.init() — sets _fallback = !detectWebGL2()
  3. draw-relief-icons.tsif (WebGL2LayerFramework.hasFallback) drawSvg(...) (added in Story 2.2)

This story adds integration-level tests that walk the full fallback path end-to-end and confirms visual parity by reviewing the SVG output structure.

Files to Touch

File Change
src/modules/webgl-layer-framework.test.ts ADD new describe block: WebGL2LayerFramework fallback path
src/renderers/draw-relief-icons.ts READ ONLY — verify hasFallback check exists (no changes needed)

Do NOT touch:

  • src/modules/webgl-layer-framework.ts — framework implementation is complete; fallback is already there
  • Business logic functions in draw-relief-icons.ts — Story 2.2 already covered those

Dev Notes

Existing Fallback Tests (Do Not Duplicate)

Story 1.1 already added tests in webgl-layer-framework.test.ts for detectWebGL2:

describe("detectWebGL2", () => {
  it("returns false when getContext returns null", () => { ... });  // FR26
  it("returns true when getContext returns a context object", () => { ... });
});

And Story 1.2 added init() tests including one for the fallback path:

describe("WebGL2LayerFrameworkClass — init()", () => {
  it("init() returns false and sets hasFallback when detectWebGL2 returns false", () => { ... });

Do not duplicate these. The new tests in this story focus on:

  1. Framework no-ops after fallback is set
  2. The integration with draw-relief-icons.ts — verifying hasFallback routes to SVG

Framework No-Op Tests

These tests verify that ALL public framework methods handle hasFallback === true gracefully. The pattern: inject _fallback = true onto the framework instance, then call every public method and assert no exception is thrown.

describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)", () => {
  let framework: WebGL2LayerFrameworkClass;

  beforeEach(() => {
    framework = new WebGL2LayerFrameworkClass();
    (framework as any)._fallback = true;
  });

  it("register() is a no-op when fallback is active (pending queue not used)", () => {
    const config = {
      id: "terrain",
      anchorLayerId: "terrain",
      renderOrder: 2,
      setup: vi.fn(),
      render: vi.fn(),
      dispose: vi.fn()
    };
    // register() before init() uses pendingConfigs[] — not gated by _fallback.
    // After init() with _fallback=true, scene is null, so register() re-queues.
    // The key assertion: no exception thrown, no setup() called.
    expect(() => framework.register(config)).not.toThrow();
    expect(config.setup).not.toHaveBeenCalled();
  });

  it("setVisible() is a no-op when fallback is active", () => {
    expect(() => framework.setVisible("terrain", false)).not.toThrow();
    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", () => {
    const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1 as any);
    expect(() => framework.requestRender()).not.toThrow();
    expect(rafSpy).not.toHaveBeenCalled();
    rafSpy.mockRestore();
  });

  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("hasFallback getter returns true when _fallback is set", () => {
    expect(framework.hasFallback).toBe(true);
  });
});

init() Fallback DOM Non-Mutation Test

Story 1.2 added a test for init() returns false when detectWebGL2 returns false but may not have verified that the DOM was NOT mutated. Add this more specific test:

it("init() with fallback does NOT create #map-container or canvas", () => {
  const fresh = new WebGL2LayerFrameworkClass();
  // Mock detectWebGL2 by spying on the canvas.getContext call in detectWebGL2
  // The cleanest way: stub document.createElement so probe canvas returns null context
  const origCreate = document.createElement.bind(document);
  vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
    if (tag === "canvas") {
      return {getContext: () => null} as unknown as HTMLCanvasElement;
    }
    return origCreate(tag);
  });

  const result = fresh.init();

  expect(result).toBe(false);
  expect(fresh.hasFallback).toBe(true);
  expect(document.getElementById("map-container")).toBeNull();
  expect(document.getElementById("terrainCanvas")).toBeNull();

  vi.restoreAllMocks();
});

Note: This test only works if dom environment is configured in Vitest. Check vitest.config.ts for environment: "jsdom" or environment: "happy-dom". If not configured, check vitest.browser.config.ts. If tests run in node environment without DOM, skip this test or mark it appropriately.

What to Check in draw-relief-icons.ts (Read-Only Verification)

After Story 2.2 is complete, verify these lines exist in draw-relief-icons.ts:

// In window.drawRelief:
if (type === "svg" || WebGL2LayerFramework.hasFallback) {
  drawSvg(icons, parentEl); // ← SVG path taken when hasFallback is true
}

// In window.undrawRelief:
WebGL2LayerFramework.clearLayer("terrain"); // ← no-op in fallback mode (returns early)

No code change is needed here — just document the verification in this story's completion notes.

Visual Parity Verification (AC4)

AC4 is verified manually or via browser test, not a Vitest unit test. The SVG fallback path uses the existing drawSvg() function which is unchanged from the pre-refactor implementation. Visual parity is therefore structural (same code path → same output). Document this in completion notes.

Vitest unit coverage for AC4: you can add a unit test that verifies drawSvg() produces the expected <use> element HTML structure:

// This test requires draw-relief-icons.ts to export drawSvg for testability,
// OR tests it indirectly via window.drawRelief with hasFallback=true.
// The latter is an integration test that exercises the full SVG path:

it("window.drawRelief() calls drawSvg when hasFallback is true", () => {
  // Stub: force hasFallback=true on the global framework
  Object.defineProperty(window.WebGL2LayerFramework, "hasFallback", {
    get: () => true,
    configurable: true
  });

  const parentEl = document.createElement("g");
  parentEl.setAttribute("set", "simple");

  // Stub pack and generateRelief
  (globalThis as any).pack = {relief: []};
  // Note: generateRelief() will be called since pack.relief is empty — it needs
  // the full browser environment (cells, biomes, etc.). For unit testing, it's
  // simpler to stub the icons directly via pack.relief:
  (globalThis as any).pack = {
    relief: [
      {i: 0, href: "#relief-mount-1", x: 100, y: 100, s: 20},
      {i: 1, href: "#relief-hill-1", x: 200, y: 150, s: 15}
    ]
  };

  window.drawRelief("webGL", parentEl); // type=webGL but hasFallback forces SVG path

  // SVG path: parentEl.innerHTML should contain <use> elements
  expect(parentEl.innerHTML).toContain('<use href="#relief-mount-1"');
  expect(parentEl.innerHTML).toContain('data-id="0"');

  // Restore hasFallback
  Object.defineProperty(window.WebGL2LayerFramework, "hasFallback", {
    get: () => false,
    configurable: true
  });
});

Caution: This integration test has significant setup requirements (global pack, window.WebGL2LayerFramework initialized, DOM element available). If the test environment doesn't support these, write a lighter version that just tests drawSvg() output format directly after exporting it (if needed). The primary goal is AC5 — all existing 34 tests still pass. The integration test here is bonus coverage.

NFR-C1 Verification

NFR-C1: "WebGL2 context is the sole gating check; if null, SVG fallback activates automatically with no user-visible error."

The existing detectWebGL2() tests in describe("detectWebGL2") already cover the gating check. Add a test confirming no console.error is emitted during the fallback path:

it("fallback activation produces no console.error", () => {
  const errorSpy = vi.spyOn(console, "error");
  const fresh = new WebGL2LayerFrameworkClass();
  (fresh as any)._fallback = true;
  fresh.register({id: "x", anchorLayerId: "x", renderOrder: 1, setup: vi.fn(), render: vi.fn(), dispose: vi.fn()});
  fresh.setVisible("x", false);
  fresh.clearLayer("x");
  fresh.requestRender();
  fresh.unregister("x");
  expect(errorSpy).not.toHaveBeenCalled();
  vi.restoreAllMocks();
});

Coverage Target

After Story 2.3, the target remains ≥80% statement coverage for webgl-layer-framework.ts (NFR-M5). The fallback guard branches (if (this._fallback) return) may already be partially covered by existing Class tests that set _fallback = false. The new tests explicitly set _fallback = true which flips the coverage on the early-return branches. This should push statement coverage higher (currently 85.13% — these tests will add 2-4%).

Vitest Environment

Check the existing test config:

  • vitest.config.ts — base config
  • vitest.browser.config.ts — browser mode config

If tests run in node environment (no DOM), DOM-dependent tests in the init() fallback DOM non-mutation section should be skipped or adapted to not use document.getElementById. Existing tests use the pattern vi.spyOn(globalThis, ...) and direct instance field injection — this pattern works in node.


Previous Story Intelligence

From Stories 1.11.3

  • detectWebGL2() pure function test pattern (inject probe canvas): fully established
  • WebGL2LayerFrameworkClass test pattern (inject _fallback, inject scene, layers): established
  • requestRender() RAF anti-pattern: uses vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1 as any) — the RAF spy MUST be restored after each test
  • Private field injection with (framework as any)._fieldName = value — established pattern for all framework tests
  • Test count baseline: 34 tests, 85.13% statement coverage after Story 1.3

From Story 2.2

  • WebGL2LayerFramework.hasFallback is checked in window.drawRelief to route to SVG path
  • WebGL2LayerFramework.clearLayer("terrain") is a no-op when _fallback === true (returns early at top of method)
  • WebGL2LayerFramework.requestRender() is a no-op when _fallback === true
  • drawSvg(icons, parentEl) is the SVG path — unchanged from pre-refactor; produces <use> elements in parentEl.innerHTML

References

  • FR18: WebGL2 unavailability detection — [Source: _bmad-output/planning-artifacts/epics.md#FR18]
  • FR19: Visually identical SVG fallback — [Source: _bmad-output/planning-artifacts/epics.md#FR19]
  • FR26: detectWebGL2 testability — [Source: _bmad-output/planning-artifacts/epics.md#FR26]
  • NFR-C1: WebGL2 sole gating check — [Source: _bmad-output/planning-artifacts/epics.md#NonFunctional Requirements]
  • NFR-C4: Hardware acceleration disabled = SVG fallback — [Source: _bmad-output/planning-artifacts/epics.md#NonFunctional Requirements]
  • Architecture Decision 6 (fallback pattern): [Source: _bmad-output/planning-artifacts/architecture.md#Decision 6]

Tasks

  • T1: Read current webgl-layer-framework.test.ts — understand existing test structure and count (34 tests baseline)

  • T2: Read current draw-relief-icons.ts (post-Story 2.2) — verify WebGL2LayerFramework.hasFallback check exists in window.drawRelief

  • T3: Add describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)") block to webgl-layer-framework.test.ts

    • T3a: register() — no exception, setup not called
    • T3b: setVisible() — no exception (both true and false; split into two tests)
    • T3c: clearLayer() — no exception
    • T3d: requestRender() — no exception, RAF not called
    • T3e: unregister() — no exception
    • T3f: syncTransform() — no exception
    • T3g: hasFallback getter returns true
    • T3h: NFR-C1 — no console.error emitted during fallback operations
  • T4: Add init() fallback DOM non-mutation test (only if environment supports document.getElementById)

    • T4a: Check Vitest environment config (vitest.config.ts) — confirmed node environment (no jsdom)
    • T4b: If jsdom/happy-dom: skip — existing init() returns false and sets hasFallback test in Story 1.2 covers this
    • T4c: node-only environment: existing test init() returns false and sets hasFallback already covers AC1
  • T5: (Optional/Bonus) Add integration test verifying window.drawRelief() SVG output when hasFallback === true

    • Not added — requires full DOM + pack globals not available in node test environment; structural analysis in completion notes is sufficient
  • T6: npx vitest run src/modules/webgl-layer-framework.test.ts

    • T6a: All existing 34 tests pass (no regressions)
    • T6b: All new fallback tests pass — 9 new tests added, 43 total
    • T6c: Statement coverage increased to 88.51% (was 85.13%) ≥80% NFR-M5
  • T7: npm run lint — zero errors

    • Result: Checked 80 files in 120ms. No fixes applied.
  • T8: Document completion:

    • T8a: New test count: 43 tests (was 34; +9 new fallback tests)
    • T8b: Final statement coverage: 88.51% (was 85.13%) — 3.38% increase from fallback branch coverage
    • T8c: AC4 SVG visual parity — verified structurally: drawSvg() is unchanged from pre-refactor; Story 2.2 only added the hasFallback dispatch — same drawSvg() code path executes for both SVG type and fallback mode, producing identical <use> element output

Dev Agent Record

Agent Model Used

Claude Sonnet 4.6 (GitHub Copilot)

Debug Log References

  • Initial requestRender() test used vi.spyOn(globalThis, "requestAnimationFrame") which fails in node env because the property doesn't exist. Fixed by using vi.stubGlobal("requestAnimationFrame", vi.fn()) + vi.unstubAllGlobals() — consistent with the pattern used in Story 1.3 tests at lines 339, 359.

Completion Notes List

  • T3 9 new tests added in describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)"). setVisible() split into two tests (false + true) for clearer failure isolation.
  • T4 Vitest runs in node env (no jsdom/happy-dom). Existing Story 1.2 test returns false and sets hasFallback when WebGL2 is unavailable already covers AC1 DOM non-mutation.
  • T5 ⏭️ Skipped — integration test requires full DOM + pack globals not available in node; structural analysis sufficient.
  • T6 43 tests passing; statement coverage 88.51% (up from 85.13%).
  • T7 npm run lintNo fixes applied.
  • AC4 Visual parity verified structurally: drawSvg() function is unchanged from pre-refactor; same code path executes for type === "svg" and hasFallback === true. Identical <use> element output guaranteed by shared code.

File List

  • src/modules/webgl-layer-framework.test.ts — new describe block with 9 fallback no-op tests (lines 564631)