fix(ui-calculator): keep calculator state long-lived; don't eject on planet click
Tests · UI / test (push) Successful in 1m59s

Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.

While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 20:29:08 +02:00
parent 9ae7b88b89
commit 3ea29cf8b5
6 changed files with 264 additions and 109 deletions
@@ -0,0 +1,121 @@
// Long-lived state for the ship-class calculator. The sidebar unmounts
// the calculator tab when another tab is active, so component-local state
// would be lost on every tab switch (the inspector auto-opens on a planet
// click, for instance). The calculator is a long-lived planning tool, so
// its inputs live here — a page-level singleton that survives tab
// unmount/remount — and the component renders this store rather than its
// own `$state`.
//
// `ensureGame` resets the design when the active game changes so a draft
// from a previous game does not leak across games. `reset` is for tests,
// which share the module instance across cases.
import type { LoadMode, LockableOutputId } from "./calc-model";
interface Blocks {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
interface Tech {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
type Mode = "ship" | "modernization";
function freshBlocks(): Blocks {
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
}
function freshTech(): Tech {
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
}
class CalculatorState {
gameId = $state<string | null>(null);
mode = $state<Mode>("ship");
name = $state("");
blocks = $state<Blocks>(freshBlocks());
techValues = $state<Tech>(freshTech());
techOverridden = $state<Record<keyof Tech, boolean>>({
drive: false,
weapons: false,
shields: false,
cargo: false,
});
targetTech = $state<Tech>(freshTech());
targetSeeded = $state(false);
loadMode = $state<LoadMode>("full");
customLoad = $state(0);
lock = $state<LockableOutputId | null>(null);
lockValue = $state(0);
matOverridden = $state(false);
matValue = $state(0);
loadedExisting = $state<string | null>(null);
// The last calculatorLoadRequest token this state has applied. Held
// here (not in the component) so a tab-switch remount does not
// re-apply the previous load request and clobber the kept design.
handledLoadToken = $state(0);
/** Clears the design back to a blank new-class form. */
resetDesign(): void {
this.blocks = freshBlocks();
this.name = "";
this.loadedExisting = null;
this.lock = null;
}
/** Full reset to defaults; used by tests sharing the singleton. */
reset(): void {
this.gameId = null;
this.mode = "ship";
this.resetDesign();
this.techValues = freshTech();
this.techOverridden = {
drive: false,
weapons: false,
shields: false,
cargo: false,
};
this.targetTech = freshTech();
this.targetSeeded = false;
this.loadMode = "full";
this.customLoad = 0;
this.lockValue = 0;
this.matOverridden = false;
this.matValue = 0;
this.handledLoadToken = 0;
}
/**
* Resets the per-game design when the active game changes, so a draft
* from one game does not surface in another. A no-op while the game is
* unchanged, which is what makes the design survive tab switches.
* `handledLoadToken` is intentionally preserved across games.
*/
ensureGame(gameId: string): void {
if (this.gameId === gameId) return;
this.gameId = gameId;
this.mode = "ship";
this.resetDesign();
this.techValues = freshTech();
this.techOverridden = {
drive: false,
weapons: false,
shields: false,
cargo: false,
};
this.targetTech = freshTech();
this.targetSeeded = false;
this.loadMode = "full";
this.customLoad = 0;
this.lockValue = 0;
this.matOverridden = false;
this.matValue = 0;
}
}
export const calculatorState = new CalculatorState();
+86 -105
View File
@@ -6,14 +6,16 @@ into one sidebar tool, and adds single-target goal-seek — the player pins
one result and the model back-solves the single input it claims (see one result and the model back-solves the single input it claims (see
`lib/calculator/calc-model.ts`). A second mode reuses the design area to `lib/calculator/calc-model.ts`). A second mode reuses the design area to
price ship-class modernization. All math comes from `pkg/calc` through price ship-class modernization. All math comes from `pkg/calc` through
the `Core` WASM bridge; this component only holds input state and renders. the `Core` WASM bridge; this component only renders + orchestrates.
State is component-local: the sidebar keeps this tab mounted while the Input state lives in the long-lived `calculatorState` singleton, not in
player navigates between active views, so inputs persist across view the component, so it survives the sidebar unmounting this tab on a tab
switches per the global state-preservation rule. switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { page } from "$app/state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { import {
@@ -40,13 +42,14 @@ switches per the global state-preservation rule.
type LockableOutputId, type LockableOutputId,
type LoadMode, type LoadMode,
} from "$lib/calculator/calc-model"; } from "$lib/calculator/calc-model";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
import ShipDesignArea, { import ShipDesignArea, {
type TechKey, type TechKey,
} from "$lib/calculator/ship-design-area.svelte"; } from "$lib/calculator/ship-design-area.svelte";
import { reachStore } from "$lib/calculator/reach.svelte"; import { reachStore } from "$lib/calculator/reach.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte"; import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
import { calculatorState } from "$lib/calculator/calc-state.svelte";
const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];
const rendered = getContext<RenderedReportSource | undefined>( const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
@@ -59,26 +62,13 @@ switches per the global state-preservation rule.
SELECTION_CONTEXT_KEY, SELECTION_CONTEXT_KEY,
); );
type Mode = "ship" | "modernization"; // The long-lived input state (survives tab unmount/remount).
let mode = $state<Mode>("ship"); const cs = calculatorState;
// Reset the design when the active game changes; a no-op otherwise, so
let name = $state(""); // the design persists across tab switches within a game.
let blocks = $state({ drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 }); $effect(() => {
let techValues = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 }); cs.ensureGame(page.params.id ?? "");
let techOverridden = $state<Record<TechKey, boolean>>({
drive: false,
weapons: false,
shields: false,
cargo: false,
}); });
let targetTech = $state({ drive: 0, weapons: 0, shields: 0, cargo: 0 });
let loadMode = $state<LoadMode>("full");
let customLoad = $state(0);
let lock = $state<LockableOutputId | null>(null);
let lockValue = $state(0);
let matOverridden = $state(false);
let matValue = $state(0);
let loadedExisting = $state<string | null>(null);
const core = $derived(coreHandle?.core ?? null); const core = $derived(coreHandle?.core ?? null);
const report = $derived(rendered?.report ?? null); const report = $derived(rendered?.report ?? null);
@@ -101,36 +91,35 @@ switches per the global state-preservation rule.
// changes, so the calculator reflects the right turn's tech. // changes, so the calculator reflects the right turn's tech.
$effect(() => { $effect(() => {
for (const k of techKeys) { for (const k of techKeys) {
if (!techOverridden[k]) techValues[k] = playerTech[k]; if (!cs.techOverridden[k]) cs.techValues[k] = playerTech[k];
} }
}); });
// Seed the modernization target with the player's current tech once // Seed the modernization target with the player's current tech once
// the report has loaded; afterwards it is the player's to edit. // the report has loaded; afterwards it is the player's to edit.
let targetSeeded = false;
$effect(() => { $effect(() => {
if (targetSeeded) return; if (cs.targetSeeded) return;
if ( if (
playerTech.drive || playerTech.drive ||
playerTech.weapons || playerTech.weapons ||
playerTech.shields || playerTech.shields ||
playerTech.cargo playerTech.cargo
) { ) {
targetTech = { ...playerTech }; cs.targetTech = { ...playerTech };
targetSeeded = true; cs.targetSeeded = true;
} }
}); });
const result = $derived( const result = $derived(
computeCalculator( computeCalculator(
{ {
blocks, blocks: cs.blocks,
driveTech: techValues.drive, driveTech: cs.techValues.drive,
weaponsTech: techValues.weapons, weaponsTech: cs.techValues.weapons,
shieldsTech: techValues.shields, shieldsTech: cs.techValues.shields,
cargoTech: techValues.cargo, cargoTech: cs.techValues.cargo,
loadMode, loadMode: cs.loadMode,
customLoad, customLoad: cs.customLoad,
lock: lock === null ? null : { output: lock, value: lockValue }, lock: cs.lock === null ? null : { output: cs.lock, value: cs.lockValue },
}, },
core, core,
), ),
@@ -146,8 +135,8 @@ switches per the global state-preservation rule.
}); });
$effect(() => { $effect(() => {
if (!matOverridden) { if (!cs.matOverridden) {
matValue = selectedPlanet?.materialsStockpile ?? 0; cs.matValue = selectedPlanet?.materialsStockpile ?? 0;
} }
}); });
@@ -159,7 +148,7 @@ switches per the global state-preservation rule.
{ {
shipMass: emptyMass, shipMass: emptyMass,
freeIndustry: selectedPlanet.freeIndustry ?? 0, freeIndustry: selectedPlanet.freeIndustry ?? 0,
material: matValue, material: cs.matValue,
resources: selectedPlanet.resources ?? 0, resources: selectedPlanet.resources ?? 0,
}, },
core, core,
@@ -171,7 +160,7 @@ switches per the global state-preservation rule.
// own planet is selected, or the calculator is in modernization mode. // own planet is selected, or the calculator is in modernization mode.
$effect(() => { $effect(() => {
const out = result.outputs; const out = result.outputs;
if (mode === "ship" && selectedPlanet !== null && out !== null) { if (cs.mode === "ship" && selectedPlanet !== null && out !== null) {
reachStore.set( reachStore.set(
{ x: selectedPlanet.x, y: selectedPlanet.y }, { x: selectedPlanet.x, y: selectedPlanet.y },
out.speedLoaded, out.speedLoaded,
@@ -202,15 +191,15 @@ switches per the global state-preservation rule.
}; };
const nameValidation = $derived( const nameValidation = $derived(
validateShipClass({ name, ...result.blocks }, { existingNames }), validateShipClass({ name: cs.name, ...result.blocks }, { existingNames }),
); );
const createMessage = $derived( const createMessage = $derived(
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]), nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
); );
const canCreate = $derived(nameValidation.ok && draft !== undefined); const canCreate = $derived(nameValidation.ok && draft !== undefined);
const canDelete = $derived( const canDelete = $derived(
loadedExisting !== null && cs.loadedExisting !== null &&
existingNames.includes(loadedExisting) && existingNames.includes(cs.loadedExisting) &&
draft !== undefined, draft !== undefined,
); );
@@ -218,21 +207,21 @@ switches per the global state-preservation rule.
const modernCosts = $derived.by(() => { const modernCosts = $derived.by(() => {
if (core === null) return null; if (core === null) return null;
const weaponsMass = core.weaponsBlockMass({ const weaponsMass = core.weaponsBlockMass({
weapons: blocks.weapons, weapons: cs.blocks.weapons,
armament: blocks.armament, armament: cs.blocks.armament,
}); });
const rows: { key: TechKey; mass: number }[] = [ const rows: { key: TechKey; mass: number }[] = [
{ key: "drive", mass: blocks.drive }, { key: "drive", mass: cs.blocks.drive },
{ key: "weapons", mass: weaponsMass ?? 0 }, { key: "weapons", mass: weaponsMass ?? 0 },
{ key: "shields", mass: blocks.shields }, { key: "shields", mass: cs.blocks.shields },
{ key: "cargo", mass: blocks.cargo }, { key: "cargo", mass: cs.blocks.cargo },
]; ];
const perBlock = rows.map((r) => ({ const perBlock = rows.map((r) => ({
key: r.key, key: r.key,
cost: core.blockUpgradeCost({ cost: core.blockUpgradeCost({
blockMass: r.mass, blockMass: r.mass,
currentTech: techValues[r.key], currentTech: cs.techValues[r.key],
targetTech: targetTech[r.key], targetTech: cs.targetTech[r.key],
}), }),
})); }));
const total = perBlock.reduce((sum, r) => sum + r.cost, 0); const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
@@ -247,59 +236,51 @@ switches per the global state-preservation rule.
} }
function onTechInput(key: TechKey): void { function onTechInput(key: TechKey): void {
techOverridden[key] = true; cs.techOverridden[key] = true;
} }
function onResetTech(key: TechKey): void { function onResetTech(key: TechKey): void {
techOverridden[key] = false; cs.techOverridden[key] = false;
} }
function onMatInput(): void { function onMatInput(): void {
matOverridden = true; cs.matOverridden = true;
} }
function resetMat(): void { function resetMat(): void {
matOverridden = false; cs.matOverridden = false;
} }
function lockOutput(output: LockableOutputId): void { function lockOutput(output: LockableOutputId): void {
if (lock !== null) return; if (cs.lock !== null) return;
lockValue = result.outputs?.[output] ?? 0; cs.lockValue = result.outputs?.[output] ?? 0;
lock = output; cs.lock = output;
} }
function unlock(): void { function unlock(): void {
lock = null; cs.lock = null;
} }
function loadExisting(clsName: string): void { function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName); const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return; if (cls === undefined) return;
blocks = { cs.blocks = {
drive: cls.drive, drive: cls.drive,
armament: cls.armament, armament: cls.armament,
weapons: cls.weapons, weapons: cls.weapons,
shields: cls.shields, shields: cls.shields,
cargo: cls.cargo, cargo: cls.cargo,
}; };
name = cls.name; cs.name = cls.name;
loadedExisting = cls.name; cs.loadedExisting = cls.name;
lock = null; cs.lock = null;
}
function resetToNew(): void {
blocks = { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
name = "";
loadedExisting = null;
lock = null;
} }
// React to the ship-classes table / bottom-tabs asking to load a // React to the ship-classes table / bottom-tabs asking to load a
// class (or start a fresh design) into the calculator. The layout // class (or start a fresh design) into the calculator. The layout
// flips the sidebar to this tab in parallel. // flips the sidebar to this tab in parallel.
let lastLoadToken = 0;
$effect(() => { $effect(() => {
const token = calculatorLoadRequest.token; const token = calculatorLoadRequest.token;
if (token === lastLoadToken) return; if (token === cs.handledLoadToken) return;
lastLoadToken = token; cs.handledLoadToken = token;
mode = "ship"; cs.mode = "ship";
if (calculatorLoadRequest.name === null) resetToNew(); if (calculatorLoadRequest.name === null) cs.resetDesign();
else loadExisting(calculatorLoadRequest.name); else loadExisting(calculatorLoadRequest.name);
}); });
@@ -320,17 +301,17 @@ switches per the global state-preservation rule.
shields: created.shields, shields: created.shields,
cargo: created.cargo, cargo: created.cargo,
}); });
loadedExisting = created.name; cs.loadedExisting = created.name;
} }
async function deleteClass(): Promise<void> { async function deleteClass(): Promise<void> {
if (loadedExisting === null || draft === undefined) return; if (cs.loadedExisting === null || draft === undefined) return;
await draft.add({ await draft.add({
kind: "removeShipClass", kind: "removeShipClass",
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: loadedExisting, name: cs.loadedExisting,
}); });
loadedExisting = null; cs.loadedExisting = null;
} }
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({ const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
@@ -344,12 +325,12 @@ switches per the global state-preservation rule.
</script> </script>
{#snippet lockable(output: LockableOutputId, value: number | undefined)} {#snippet lockable(output: LockableOutputId, value: number | undefined)}
{#if lock === output} {#if cs.lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}> <span class="cell locked" class:infeasible={!result.lockFeasible}>
<input <input
type="number" type="number"
step="0.001" step="0.001"
bind:value={lockValue} bind:value={cs.lockValue}
data-testid={`calculator-locked-${output}`} data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")} title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/> />
@@ -370,8 +351,8 @@ switches per the global state-preservation rule.
<button <button
type="button" type="button"
class="lock" class="lock"
disabled={lock !== null || value === undefined} disabled={cs.lock !== null || value === undefined}
title={lock !== null title={cs.lock !== null
? i18n.t("game.calculator.lock.max") ? i18n.t("game.calculator.lock.max")
: `${LOCK_LABELS[output]}`} : `${LOCK_LABELS[output]}`}
aria-label={LOCK_LABELS[output]} aria-label={LOCK_LABELS[output]}
@@ -388,17 +369,17 @@ switches per the global state-preservation rule.
<div class="modes" role="tablist"> <div class="modes" role="tablist">
<button <button
type="button" type="button"
class:active={mode === "ship"} class:active={cs.mode === "ship"}
data-testid="calculator-mode-ship" data-testid="calculator-mode-ship"
onclick={() => (mode = "ship")} onclick={() => (cs.mode = "ship")}
> >
{i18n.t("game.calculator.mode.ship")} {i18n.t("game.calculator.mode.ship")}
</button> </button>
<button <button
type="button" type="button"
class:active={mode === "modernization"} class:active={cs.mode === "modernization"}
data-testid="calculator-mode-modernization" data-testid="calculator-mode-modernization"
onclick={() => (mode = "modernization")} onclick={() => (cs.mode = "modernization")}
> >
{i18n.t("game.calculator.mode.modernization")} {i18n.t("game.calculator.mode.modernization")}
</button> </button>
@@ -411,9 +392,9 @@ switches per the global state-preservation rule.
list="calculator-existing-classes" list="calculator-existing-classes"
placeholder={i18n.t("game.calculator.name.placeholder")} placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30" maxlength="30"
bind:value={name} bind:value={cs.name}
oninput={() => (loadedExisting = null)} oninput={() => (cs.loadedExisting = null)}
onchange={() => loadExisting(name)} onchange={() => loadExisting(cs.name)}
aria-invalid={nameValidation.ok ? "false" : "true"} aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name" data-testid="calculator-name"
/> />
@@ -422,7 +403,7 @@ switches per the global state-preservation rule.
<option value={cls.name}></option> <option value={cls.name}></option>
{/each} {/each}
</datalist> </datalist>
{#if mode === "ship"} {#if cs.mode === "ship"}
<button <button
type="button" type="button"
class="create" class="create"
@@ -435,49 +416,49 @@ switches per the global state-preservation rule.
</button> </button>
{/if} {/if}
</div> </div>
{#if mode === "ship" && canDelete} {#if cs.mode === "ship" && canDelete}
<button <button
type="button" type="button"
class="delete" class="delete"
data-testid="calculator-delete" data-testid="calculator-delete"
onclick={() => void deleteClass()} onclick={() => void deleteClass()}
> >
{i18n.t("game.calculator.action.delete")} {loadedExisting} {i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
</button> </button>
{/if} {/if}
<ShipDesignArea <ShipDesignArea
bind:blocks bind:blocks={cs.blocks}
resolved={result.blocks} resolved={result.blocks}
bind:techs={techValues} bind:techs={cs.techValues}
{techOverridden} techOverridden={cs.techOverridden}
computedInput={result.computedInput} computedInput={result.computedInput}
{onTechInput} {onTechInput}
{onResetTech} {onResetTech}
/> />
{#if mode === "ship"} {#if cs.mode === "ship"}
<div class="load"> <div class="load">
<span class="label">{i18n.t("game.calculator.load.label")}</span> <span class="label">{i18n.t("game.calculator.load.label")}</span>
<div class="seg" role="group"> <div class="seg" role="group">
{#each LOAD_MODES as m (m)} {#each LOAD_MODES as m (m)}
<button <button
type="button" type="button"
class:active={loadMode === m} class:active={cs.loadMode === m}
data-testid={`calculator-load-${m}`} data-testid={`calculator-load-${m}`}
onclick={() => (loadMode = m)} onclick={() => (cs.loadMode = m)}
> >
{i18n.t(`game.calculator.load.${m}` as TranslationKey)} {i18n.t(`game.calculator.load.${m}` as TranslationKey)}
</button> </button>
{/each} {/each}
</div> </div>
{#if loadMode === "custom"} {#if cs.loadMode === "custom"}
<input <input
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
class="custom-load" class="custom-load"
bind:value={customLoad} bind:value={cs.customLoad}
data-testid="calculator-custom-load" data-testid="calculator-custom-load"
/> />
{/if} {/if}
@@ -548,11 +529,11 @@ switches per the global state-preservation rule.
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
bind:value={matValue} bind:value={cs.matValue}
oninput={onMatInput} oninput={onMatInput}
data-testid="calculator-planet-mat" data-testid="calculator-planet-mat"
/> />
{#if matOverridden} {#if cs.matOverridden}
<button <button
type="button" type="button"
class="lock active" class="lock active"
@@ -598,7 +579,7 @@ switches per the global state-preservation rule.
type="number" type="number"
step="0.001" step="0.001"
min="0" min="0"
bind:value={targetTech[row.key]} bind:value={cs.targetTech[row.key]}
data-testid={`calculator-target-${row.key}`} data-testid={`calculator-target-${row.key}`}
/> />
</span> </span>
+1 -1
View File
@@ -73,7 +73,7 @@ export function computeReachCircles(
style: { style: {
strokeColor: REACH_CIRCLE_COLOR, strokeColor: REACH_CIRCLE_COLOR,
strokeAlpha: 0.55 - (turn - 1) * 0.12, strokeAlpha: 0.55 - (turn - 1) * 0.12,
strokeWidthPx: 1, strokeWidthPx: 0.5,
}, },
}); });
} }
@@ -43,7 +43,7 @@ the next game's snapshot — and the next game's selection — start
fresh. fresh.
--> -->
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, setContext } from "svelte"; import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import Header from "$lib/header/header.svelte"; import Header from "$lib/header/header.svelte";
@@ -223,7 +223,17 @@ fresh.
$effect(() => { $effect(() => {
const sel = selection.selected; const sel = selection.selected;
if (sel === null) return; if (sel === null) return;
// Stay in the calculator when a planet is picked: the calculator
// consumes the selection in its planet area + reach circles, and
// it is a long-lived workspace the user should not be ejected
// from. `activeTab` is read untracked so a manual tab switch does
// not re-fire this effect. Any other case (including a ship-group
// selection, which the calculator does not use) reveals the
// inspector as before.
const tab = untrack(() => activeTab);
if (!(tab === "calculator" && sel.kind === "planet")) {
activeTab = "inspector"; activeTab = "inspector";
}
sidebarOpen = true; sidebarOpen = true;
}); });
+8 -1
View File
@@ -7,10 +7,15 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// The calculator reads `page.params.id` to scope its long-lived state to
// the active game; stub a stable id so the component test has a router.
vi.mock("$app/state", () => ({ page: { params: { id: "calc-test-game" } } }));
import { i18n } from "../src/lib/i18n/index.svelte"; import { i18n } from "../src/lib/i18n/index.svelte";
import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte"; import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte";
import { calculatorState } from "../src/lib/calculator/calc-state.svelte";
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte"; import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
import { import {
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
@@ -106,6 +111,8 @@ beforeEach(async () => {
draft = new OrderDraftStore(); draft = new OrderDraftStore();
await draft.init({ cache: new IDBCache(db), gameId: GAME_ID }); await draft.init({ cache: new IDBCache(db), gameId: GAME_ID });
i18n.resetForTests("en"); i18n.resetForTests("en");
// The calculator state is a module singleton shared across cases.
calculatorState.reset();
}); });
afterEach(async () => { afterEach(async () => {
@@ -278,3 +278,39 @@ test("calculator draws reach circles for the selected planet", async ({
await calc.getByTestId("calculator-mode-modernization").click(); await calc.getByTestId("calculator-mode-modernization").click();
await expect.poll(() => countReachCircles(page)).toBe(0); await expect.poll(() => countReachCircles(page)).toBe(0);
}); });
test("calculator stays put on a planet click and keeps state across tab switches", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"calculator is a desktop-sidebar flow",
);
await setupShell(page);
// Open the calculator and enter a design.
await page.getByTestId("sidebar-tab-calculator").click();
await page.getByTestId("calculator-block-drive").fill("10");
// Clicking a planet must NOT eject us to the inspector; it feeds the
// calculator's planet area instead, and the design is untouched.
await clickCanvasCentre(page);
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
await expect(page.getByTestId("calculator-planet-name")).toContainText(
"Galactica",
);
await expect(page.getByTestId("calculator-block-drive")).toHaveValue("10");
// Switching to the inspector and back keeps the design (long-lived
// tool state survives the tab unmount/remount).
await page.getByTestId("sidebar-tab-inspector").click();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"inspector",
);
await page.getByTestId("sidebar-tab-calculator").click();
await expect(page.getByTestId("calculator-block-drive")).toHaveValue("10");
});