mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-23 23:57: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.
384 lines
18 KiB
Markdown
384 lines
18 KiB
Markdown
# 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 `<use>` 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 <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:
|
||
|
||
```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 `<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
|
||
|
||
- [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 `<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 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 `<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 564–631)
|