# 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.1–1.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.ts` — `if (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`: ```typescript 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: ```typescript 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. ```typescript 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: ```typescript 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`: ```typescript // 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 `` element HTML structure: ```typescript // 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 elements expect(parentEl.innerHTML).toContain(' 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: ```typescript 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.1–1.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 `` 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 - [x] **T1:** Read current `webgl-layer-framework.test.ts` — understand existing test structure and count (34 tests baseline) - [x] **T2:** Read current `draw-relief-icons.ts` (post-Story 2.2) — verify `WebGL2LayerFramework.hasFallback` check exists in `window.drawRelief` - [x] **T3:** Add `describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)")` block to `webgl-layer-framework.test.ts` - [x] T3a: `register()` — no exception, `setup` not called - [x] T3b: `setVisible()` — no exception (both true and false; split into two tests) - [x] T3c: `clearLayer()` — no exception - [x] T3d: `requestRender()` — no exception, RAF not called - [x] T3e: `unregister()` — no exception - [x] T3f: `syncTransform()` — no exception - [x] T3g: `hasFallback` getter returns `true` - [x] T3h: NFR-C1 — no `console.error` emitted during fallback operations - [x] **T4:** Add `init()` fallback DOM non-mutation test (only if environment supports `document.getElementById`) - [x] T4a: Check Vitest environment config (`vitest.config.ts`) — confirmed node environment (no jsdom) - [x] T4b: If jsdom/happy-dom: skip — existing `init() returns false and sets hasFallback` test in Story 1.2 covers this - [x] 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 - [x] **T6:** `npx vitest run src/modules/webgl-layer-framework.test.ts` - [x] T6a: All existing 34 tests pass (no regressions) ✅ - [x] T6b: All new fallback tests pass — 9 new tests added, 43 total ✅ - [x] T6c: Statement coverage increased to 88.51% (was 85.13%) ≥80% NFR-M5 ✅ - [x] **T7:** `npm run lint` — zero errors - Result: `Checked 80 files in 120ms. No fixes applied.` ✅ - [x] **T8:** Document completion: - [x] T8a: New test count: 43 tests (was 34; +9 new fallback tests) - [x] T8b: Final statement coverage: 88.51% (was 85.13%) — 3.38% increase from fallback branch coverage - [x] 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 `` 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 lint` → `No 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 `` 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 564–631)