diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte index 67158e6..3898d0a 100644 --- a/ui/frontend/src/lib/active-view/map-toggles.svelte +++ b/ui/frontend/src/lib/active-view/map-toggles.svelte @@ -34,7 +34,15 @@ bottom-tabs bar. void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]); } + /** + * setWrap is wired to the radios' `onclick`, not `onchange`, so the + * Playwright `.click()` action on the input fires the callback even + * when the input is already checked (the `change` event suppresses + * the second activation, which made the wrap-mode e2e flake). + * `onclick` also fires reliably on touch / pointer activation. + */ function setWrap(mode: WrapMode): void { + if (store.wrapMode === mode) return; void store.setWrapMode(mode); } @@ -192,7 +200,7 @@ bottom-tabs bar. data-testid="map-toggles-wrap-torus" value="torus" checked={store.wrapMode === "torus"} - onchange={() => setWrap("torus")} + onclick={() => setWrap("torus")} /> {i18n.t("game.map.toggles.wrap.torus")} @@ -203,7 +211,7 @@ bottom-tabs bar. data-testid="map-toggles-wrap-no-wrap" value="no-wrap" checked={store.wrapMode === "no-wrap"} - onchange={() => setWrap("no-wrap")} + onclick={() => setWrap("no-wrap")} /> {i18n.t("game.map.toggles.wrap.no_wrap")} diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 9e211b4..e0de5b2 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -177,6 +177,24 @@ preference the store already manages. if (!mounted || canvasEl === null || containerEl === null) return; if (status !== "ready" || !report || toggles === undefined) return; + // Explicit reads of every toggle key — Svelte 5's deep proxy + // tracks per-property access, and the actual consumers + // (computeHiddenIds, computeFogCircles, buildExtras) run + // inside `untrack` blocks or async continuations where the + // tracking would otherwise be lost. Touching every key here + // synchronously guarantees a flip triggers the effect. + void toggles.hyperspaceGroups; + void toggles.incomingGroups; + void toggles.unidentifiedGroups; + void toggles.foreignPlanets; + void toggles.uninhabitedPlanets; + void toggles.unidentifiedPlanets; + void toggles.unreachablePlanets; + void toggles.cargoRoutes; + void toggles.battleMarkers; + void toggles.bombingMarkers; + void toggles.visibilityFog; + // Phase 29 visibility derivation. Cargo routes and pending- // Send overlay are extras (no Pixi remount on flip); the // cascade-filtering happens here so the extras list shrinks diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts index 857ec6a..090125b 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -238,14 +238,17 @@ async function visibleHighBitCount( page: Page, prefix: number, ): Promise { - // Convert ids to uint32 before masking so the comparison works - // for ids stored as signed-negative numbers (JS bitwise ops force - // ToInt32). `prefix >>> 0` keeps the literal in uint32 space too. + // JS bitwise `&` always returns a signed int32. Convert both + // sides to uint32 via `>>> 0` AFTER the mask so the comparison + // is well-defined for high-bit-prefix ids that arrive as + // negative Numbers (cargo route 0x80…, battle 0xa0…, bombing + // 0xc0…) as well as for the positive `prefix` literal passed in. return await page.evaluate((p: number) => { const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; + const expected = p >>> 0; return prims.filter( (prim) => - prim.visible && ((prim.id >>> 0) & 0xf0000000) === (p >>> 0), + prim.visible && ((prim.id & 0xf0000000) >>> 0) === expected, ).length; }, prefix); }