- Implemented registration of draw-relief-icons with WebGL2LayerFramework, removing module-level renderer state. - Updated drawRelief, undrawRelief, and rerenderReliefIcons functions to utilize framework methods. - Ensured SVG fallback path is preserved and functional. - Added performance criteria for rendering relief icons. - Created tests to verify fallback integration and visual parity with existing SVG output. test: Add WebGL2 fallback integration verification - Introduced new tests for WebGL2LayerFramework to ensure no-ops when fallback is active. - Verified that drawRelief routes to SVG when WebGL2 is unavailable. - Confirmed visual parity between SVG output and existing implementation. - Ensured all tests pass with updated coverage metrics.
16 KiB
Story 2.3: WebGL2 Fallback Integration Verification
Status: ready-for-dev 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.tsusesWebGL2LayerFramework.hasFallbackto route todrawSvg(). 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:
detectWebGL2()— exported pure function (already tested in Story 1.1 with 2 tests)WebGL2LayerFrameworkClass.init()— sets_fallback = !detectWebGL2()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:
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:
- Framework no-ops after fallback is set
- The integration with
draw-relief-icons.ts— verifyinghasFallbackroutes 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
domenvironment is configured in Vitest. Checkvitest.config.tsforenvironment: "jsdom"orenvironment: "happy-dom". If not configured, checkvitest.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.WebGL2LayerFrameworkinitialized, DOM element available). If the test environment doesn't support these, write a lighter version that just testsdrawSvg()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 configvitest.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 establishedWebGL2LayerFrameworkClasstest pattern (inject_fallback, injectscene,layers): establishedrequestRender()RAF anti-pattern: usesvi.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.hasFallbackis checked inwindow.drawReliefto route to SVG pathWebGL2LayerFramework.clearLayer("terrain")is a no-op when_fallback === true(returns early at top of method)WebGL2LayerFramework.requestRender()is a no-op when_fallback === truedrawSvg(icons, parentEl)is the SVG path — unchanged from pre-refactor; produces<use>elements inparentEl.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) — verifyWebGL2LayerFramework.hasFallbackcheck exists inwindow.drawRelief -
T3: Add
describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)")block towebgl-layer-framework.test.ts- T3a:
register()— no exception,setupnot called - T3b:
setVisible()— no exception (both true and false) - T3c:
clearLayer()— no exception - T3d:
requestRender()— no exception, RAF not called - T3e:
unregister()— no exception - T3f:
syncTransform()— no exception - T3g:
hasFallbackgetter returnstrue - T3h: NFR-C1 — no
console.erroremitted during fallback operations
- T3a:
-
T4: Add
init()fallback DOM non-mutation test (only if environment supportsdocument.getElementById)- T4a: Check Vitest environment config (
vitest.config.ts) - T4b: If jsdom/happy-dom: add test asserting
#map-containerand#terrainCanvasdo NOT exist after fallbackinit() - T4c: If node-only environment: skip DOM assertion; rely on
init() returns falseandhasFallback === truetests
- T4a: Check Vitest environment config (
-
T5: (Optional/Bonus) Add integration test verifying
window.drawRelief()SVG output whenhasFallback === true- T5a: Stub
hasFallbackon global framework instance - T5b: Create DOM element, populate
pack.reliefstub data - T5c: Call
window.drawRelief("webGL", element)— assert SVG output contains<use>elements - T5d: Restore stubs
- T5a: Stub
-
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
- T6c: Statement coverage remains ≥80% (NFR-M5)
-
T7:
npm run lint— zero errors -
T8: Document completion:
- T8a: Record actual test count after new tests
- T8b: Record final statement coverage percentage
- T8c: Verify AC4 (SVG visual parity) by manual or structural analysis — document finding
Dev Agent Record
Agent Model Used
to be filled by dev agent
Debug Log References
Completion Notes List
File List
Files modified (to be filled by dev agent):
src/modules/webgl-layer-framework.test.ts— new describe block with 8+ fallback tests