fix(battle-viewer): unblock synthetic-game battle load
Tests · UI / test (push) Successful in 2m18s
Tests · UI / test (pull_request) Waiting to run

The Phase 28 ConnectRPC migration of the battle viewer added a
guard in `lib/active-view/battle.svelte` that waits for the
surrounding layout to publish a `GalaxyClient` before issuing the
fetch. The in-game shell layout deliberately skips
`galaxyClient.set(...)` on the synthetic branch (gateway is not
reachable in synthetic mode), so for any battle opened from a
synthetic-report game the viewer sat on "loading battle…"
forever — `fetchBattle` was never called, so the synthetic-fixture
short-circuit it carries was unreachable.

Let the guard skip synthetic ids: `fetchBattle` already resolves
those through `lookupSyntheticBattle` and never touches the
client, so its signature widens to `GalaxyClient | null` and the
synthetic path passes `null`. The live path still waits for the
handle as before; a `null` client on the live path now fails
fast with a transport-level `BattleFetchError` instead of silently
sitting on `loading`.

Tests:
- Existing "loading placeholder" smoke now uses a non-synthetic
  game id so it keeps asserting the live-path wait.
- Two new cases pin the synthetic behaviour: missing fixture →
  `battle-not-found`; registered fixture → `BattleViewer` mounts.

Docs:
- `docs/FUNCTIONAL.md` §6.5 still described the pre-Phase-28
  raw REST path. Updated to the signed ConnectRPC command and
  noted the synthetic short-circuit. Russian mirror updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 07:52:26 +02:00
parent 82bdb6777a
commit bde01b1ce2
5 changed files with 100 additions and 17 deletions
+64 -3
View File
@@ -15,6 +15,11 @@ import { render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import {
registerSyntheticBattle,
resetSyntheticBattles,
} from "../src/api/synthetic-battle";
import type { BattleReport } from "../src/api/battle-fetch";
import MapView from "../src/lib/active-view/map.svelte";
import TableView from "../src/lib/active-view/table.svelte";
@@ -24,6 +29,7 @@ import MailView from "../src/lib/active-view/mail.svelte";
beforeEach(() => {
i18n.resetForTests("en");
resetSyntheticBattles();
});
describe("active-view stubs", () => {
@@ -76,16 +82,23 @@ describe("active-view stubs", () => {
);
});
test("battle view stamps the battleId and shows the loading placeholder", () => {
test("battle view stamps the battleId and shows the loading placeholder for a live game", () => {
// Phase 27 replaces the Phase 10 stub with the Battle Viewer
// wrapper. The latest layout iteration moved the back-
// navigation buttons inside `BattleViewer` so they only mount
// once the BattleReport finishes loading. The wrapper itself
// always renders the `active-view-battle` host with the
// `data-battle-id` stamp and a localized loading copy until
// the fetcher resolves.
// the fetcher resolves. For a live game id the wrapper also
// waits for the surrounding layout to publish a `GalaxyClient`
// before issuing the fetch — without the context here the
// effect stays in `loading` as designed.
const ui = render(BattleView, {
props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
props: {
gameId: "00000000-0000-0000-0000-000000000010",
turn: 0,
battleId: "b-42",
},
});
const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42");
@@ -102,4 +115,52 @@ describe("active-view stubs", () => {
);
expect(ui.getByTestId("battle-not-found")).toBeInTheDocument();
});
test("battle view surfaces not-found for a synthetic game when no fixture is registered", async () => {
// Synthetic games never publish a GalaxyClient — the in-game
// shell layout deliberately skips `galaxyClient.set(...)` on
// that branch. The viewer must resolve the fixture (or its
// absence) without waiting on the client handle; if it did
// wait, the view would sit on `loading` indefinitely because
// the handle never lands. `fetchBattle` itself is `async`, so
// even the synchronous fixture-miss path resolves on a
// microtask — `findByTestId` lets the BattleViewer wrapper
// flush its rejection handler before the assertion.
const ui = render(BattleView, {
props: {
gameId: "synthetic-test",
turn: 0,
battleId: "missing-fixture",
},
});
await ui.findByTestId("battle-not-found");
});
test("battle view renders a synthetic fixture without a GalaxyClient context", async () => {
// Regression for the dev-deploy bug where the viewer
// short-circuited to `loading` for every cross click on a
// synthetic-report game. The fixture below mirrors the shape
// `fetchBattle` returns for the live path; once
// `lookupSyntheticBattle` resolves it, the wrapper transitions
// to `ready` and mounts the BattleViewer scene.
const fixture: BattleReport = {
id: "fixture-battle",
planet: 1,
planetName: "Earth",
races: { "0": "00000000-0000-0000-0000-0000000000aa" },
ships: {},
protocol: [],
};
registerSyntheticBattle(fixture);
const ui = render(BattleView, {
props: {
gameId: "synthetic-test",
turn: 0,
battleId: "fixture-battle",
},
});
await ui.findByTestId("battle-viewer");
expect(ui.queryByTestId("battle-loading")).not.toBeInTheDocument();
});
});