ui/phase-14: auto-sync order draft + always GET on boot + header headline

Replaces the manual Submit button with an auto-sync pipeline driven
by `OrderDraftStore`: every successful add / remove / move
coalesces a `submitOrder` call so the engine always mirrors the
local draft. Removing the last command sends an empty cmd[] PUT —
the engine, repo, and rest model now accept that as a valid
"player cleared their draft" state.

`hydrateFromServer` is now invoked unconditionally on game boot so
a fresh device picks up the player's stored order, and the local
cache is overwritten by the server's view (server is the source of
truth).

Header replaces the static "race ?" + turn counter with a single
headline string `<race> @ <game>, turn <n>`, sourced from the
engine's Report.race + the lobby's GameSummary.gameName + the live
turn number, with a `?` fallback while any piece is loading.

Tests:
- engine: empty PUT round-trips, repo round-trips empty Commands
- order-draft: auto-sync sends full draft on every mutation,
  rejected response surfaces error sync status, rapid mutations
  coalesce, server hydration overwrites cache
- order-tab: per-row status flips through the auto-sync lifecycle,
  remove → empty cmd[] PUT, rejected → retry button
- inspector overlay: applied + valid + submitting all participate
  in the optimistic projection
- header: live race / game / turn rendering with fall-back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 13:34:10 +02:00
parent 68d8607eaa
commit 229c43beb5
26 changed files with 1144 additions and 728 deletions
+5 -5
View File
@@ -12,7 +12,6 @@ package repo
import (
"encoding/json"
"errors"
"fmt"
"galaxy/model/order"
@@ -234,15 +233,16 @@ func loadOrder(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, er
if err := s.ReadSafe(path, stored); err != nil {
return nil, false, NewStorageError(err)
}
// An empty stored batch is a valid state — the player either
// cleared their draft or never added a command yet. We round-
// trip it as `(*UserGamesOrder, true, nil)` with an empty
// `Commands` slice so callers can distinguish "no order yet"
// (ok=false) from "order exists but is empty" (ok=true).
result := &order.UserGamesOrder{
GameID: stored.GameID,
UpdatedAt: stored.UpdatedAt,
Commands: make([]order.DecodableCommand, len(stored.Commands)),
}
if len(stored.Commands) == 0 {
return nil, false, errors.New("no commands were stored")
}
for i := range stored.Commands {
command, err := ParseOrder(stored.Commands[i], nil)
if err != nil {
+44
View File
@@ -104,6 +104,50 @@ func LoadOrderTest(t *testing.T, s repo.Storage, root string, turn uint, id uuid
CommandResultTest(t, o)
}
func TestSaveOrderEmptyRoundTrip(t *testing.T) {
// An empty order is a legal player intent (the user removed
// every command from the draft). The repo round-trips it as an
// `(*UserGamesOrder, true, nil)` triple with `Commands` empty
// so the front-end can distinguish "no order yet" (ok=false)
// from "order exists but is empty" (ok=true).
root := t.ArtifactDir()
s, err := fs.NewFileStorage(root)
assert.NoError(t, err)
id := uuid.New()
gameID := uuid.New()
now := time.Now().UTC().UnixMilli()
o := &order.UserGamesOrder{
GameID: gameID,
UpdatedAt: now,
}
var turn uint = 3
assert.NoError(t, repo.SaveOrder_T(s, turn, id, o))
assert.FileExists(t, filepath.Join(root, repo.OrderDir(turn, id)))
loaded, ok, err := repo.LoadOrder_T(s, turn, id)
assert.NoError(t, err)
assert.True(t, ok, "empty order must surface as ok=true so callers can tell it apart from a missing one")
assert.NotNil(t, loaded)
assert.Equal(t, gameID, loaded.GameID)
assert.Equal(t, now, loaded.UpdatedAt)
assert.Empty(t, loaded.Commands)
}
func TestLoadOrderMissing(t *testing.T) {
// A turn that has never had a PUT must come back as
// `(nil, false, nil)` — the engine's "no stored order" path.
root := t.ArtifactDir()
s, err := fs.NewFileStorage(root)
assert.NoError(t, err)
id := uuid.New()
loaded, ok, err := repo.LoadOrder_T(s, 7, id)
assert.NoError(t, err)
assert.False(t, ok)
assert.Nil(t, loaded)
}
func CommandResultTest(t *testing.T, o *order.UserGamesOrder) {
assert.NotEmpty(t, o.Commands)
for i := range o.Commands {
+6 -2
View File
@@ -2,7 +2,6 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -33,7 +32,12 @@ func CommandHandler(c *gin.Context, executor CommandExecutor) {
commands[i] = command
}
if len(commands) == 0 {
errorResponse(c, errors.New("no commands given"))
// `PUT /api/v1/command` is the immediate-execution path —
// running an empty batch is a meaningless no-op, so we
// reject it with `400` rather than rely on the validator.
// `PUT /api/v1/order` keeps an empty list (the player
// cleared their draft) — see `OrderHandler`.
c.JSON(http.StatusBadRequest, gin.H{"error": "no commands given"})
return
}
+4 -5
View File
@@ -1,7 +1,6 @@
package handler
import (
"errors"
"net/http"
"galaxy/model/order"
@@ -18,6 +17,10 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
return
}
// An empty `cmd` array is a valid PUT: the client clears its
// local order draft and expects the server to mirror that
// state. The engine stores the empty batch so the next GET
// returns the same empty list with the new `updatedAt`.
commands := make([]order.DecodableCommand, len(cmd.Commands))
for i := range cmd.Commands {
command, err := repo.ParseOrder(cmd.Commands[i], validateCommand)
@@ -26,10 +29,6 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
}
commands[i] = command
}
if len(commands) == 0 {
errorResponse(c, errors.New("no commands given"))
return
}
result, err := executor.ValidateOrder(cmd.Actor, commands...)
if errorResponse(c, err) {
+12 -3
View File
@@ -60,16 +60,25 @@ func TestOrderRaceQuit(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// error: no commands
// empty cmd[] is a valid PUT — the player cleared their draft;
// the engine stores the empty batch and answers with the
// canonical `UserGamesOrder` envelope. ValidateOrder receives a
// zero-length variadic and the response carries no commands.
payload = &rest.Command{
Actor: commandDefaultActor,
}
exec := &dummyExecutor{}
emptyRouter := setupRouterExecutor(exec)
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
emptyRouter.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
assert.Equal(t, 0, exec.CommandsExecuted)
var stored order.UserGamesOrder
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &stored))
assert.Empty(t, stored.Commands)
}
func TestOrderRaceVote(t *testing.T) {
+8 -1
View File
@@ -4,7 +4,14 @@ import "encoding/json"
type Command struct {
Actor string `json:"actor" binding:"notblank"`
Commands []json.RawMessage `json:"cmd" binding:"min=1"`
// Commands carries the engine-bound payload for either the
// command (`PUT /api/v1/command`, immediate) or the order
// (`PUT /api/v1/order`, validate-and-store) path. The order
// path treats an empty array as "the player has no orders for
// this turn" and stores it. The command handler still rejects
// an empty array by hand because immediate execution of a
// no-op makes no sense.
Commands []json.RawMessage `json:"cmd"`
}
func (o Command) MarshalBinary() (data []byte, err error) {
+27 -12
View File
@@ -67,6 +67,12 @@ export interface GameReport {
mapHeight: number;
planetCount: number;
planets: ReportPlanet[];
/**
* race is the calling player's race name as resolved by the
* engine from the runtime player mapping. Empty when the engine
* has not produced a report yet (boot state).
*/
race: string;
}
export async function fetchGameReport(
@@ -189,6 +195,7 @@ function decodeReport(report: Report): GameReport {
mapHeight: report.height(),
planetCount: report.planetCount(),
planets,
race: report.race() ?? "",
};
}
@@ -212,18 +219,20 @@ export function uuidToHiLo(value: string): [bigint, bigint] {
}
/**
* applyOrderOverlay returns a copy of `report` with every applied or
* still-in-flight (`submitting`) command from `commands` projected on
* top. Phase 14 understands `planetRename` only — every other variant
* passes through. The function is pure: callers re-derive the
* overlay whenever the draft or the report change.
* applyOrderOverlay returns a copy of `report` with every locally-
* valid or still-in-flight or applied command from `commands`
* projected on top. Phase 14 understands `planetRename` only —
* every other variant passes through. The function is pure:
* callers re-derive the overlay whenever the draft or the report
* change.
*
* `statuses` maps command id → status. Entries with `applied` or
* `submitting` participate in the overlay; everything else (`draft`,
* `valid`, `invalid`, `rejected`) is treated as "not yet committed
* by the player" and skipped. This matches the order-composer model:
* the player sees their own committed intent, not their unfinished
* edits.
* `statuses` maps command id → status. Entries with `valid`,
* `submitting`, or `applied` participate in the overlay — together
* they describe "the player's committed intent for this turn":
* locally-valid (auto-sync about to fire), in-flight on the wire,
* or acknowledged by the engine. Entries with `draft`, `invalid`,
* or `rejected` skip the overlay so the player keeps the server's
* (un-renamed) view.
*/
export function applyOrderOverlay(
report: GameReport,
@@ -234,7 +243,13 @@ export function applyOrderOverlay(
let mutatedPlanets: ReportPlanet[] | null = null;
for (const cmd of commands) {
const status = statuses[cmd.id];
if (status !== "applied" && status !== "submitting") continue;
if (
status !== "valid" &&
status !== "submitting" &&
status !== "applied"
) {
continue;
}
if (cmd.kind !== "planetRename") continue;
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
if (idx < 0) continue;
+8
View File
@@ -37,6 +37,13 @@ type Status = "idle" | "loading" | "ready" | "error";
export class GameStateStore {
gameId: string = $state("");
/**
* gameName mirrors the lobby's `game_name` for the running game.
* Lifted from the lobby record on `setGame`; empty during boot
* and set once the lobby query resolves. Used by the header to
* compose the `<race> @ <game>, turn N` display.
*/
gameName: string = $state("");
status: Status = $state("idle");
report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus");
@@ -95,6 +102,7 @@ export class GameStateStore {
this.error = `game ${gameId} is not in your list`;
return;
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn);
} catch (err) {
+47 -10
View File
@@ -1,16 +1,25 @@
<!--
Top header for the in-game shell. Composes the four artifacts called
out by `ui/PLAN.md` Phase 10: race name (static placeholder), turn
counter (static placeholder), view dropdown / hamburger, account
menu. The sidebar-toggle slot to its left appears only on tablet
viewports (7681024 px) and is wired by `+layout.svelte`.
Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name, turn N), view dropdown / hamburger, and the
account menu. The sidebar-toggle slot to its left appears only on
tablet viewports (7681024 px) and is wired by `+layout.svelte`.
The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece
is missing (boot, network error) we fall back to the
`game.shell.unknown` placeholder so the header chrome keeps its
shape.
The connection-state indicator from the IA section is intentionally
absent until Phase 24 wires push-event state.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import TurnCounter from "./turn-counter.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte";
@@ -20,14 +29,42 @@ absent until Phase 24 wires push-event state.
onToggleSidebar: () => void;
};
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const raceName = $derived.by(() => {
const name = gameState?.report?.race;
return name === undefined || name === ""
? i18n.t("game.shell.unknown")
: name;
});
const gameName = $derived.by(() => {
const name = gameState?.gameName ?? "";
return name === "" ? i18n.t("game.shell.unknown") : name;
});
const turn = $derived.by(() => {
const report = gameState?.report;
return report === null || report === undefined
? i18n.t("game.shell.unknown")
: String(report.turn);
});
const headline = $derived(
i18n.t("game.shell.headline", {
race: raceName,
game: gameName,
turn,
}),
);
</script>
<header class="game-shell-header" data-testid="game-shell-header">
<div class="left">
<span class="race" data-testid="race-name">
{i18n.t("game.shell.race_placeholder")}
<span class="headline" data-testid="game-shell-headline">
{headline}
</span>
<TurnCounter />
</div>
<div class="right">
<button
@@ -69,7 +106,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem;
min-width: 0;
}
.race {
.headline {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -1,37 +0,0 @@
<!--
Phase 11 turn counter: reads the live turn number from the per-game
`GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. Renders the static `?` placeholder
from `game.shell.turn_unknown` when the store has not yet produced a
report (boot, network error, no membership) so the header chrome
keeps its width across loading transitions.
Phase 26 will turn this into a clickable trigger that opens the
turn navigator; Phase 24 wires push-event-driven turn-ready toasts
that may flash this counter when a new turn is ready.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { GAME_STATE_CONTEXT_KEY, type GameStateStore } from "$lib/game-state.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const display = $derived.by(() => {
const report = store?.report ?? null;
if (report === null) return i18n.t("game.shell.turn_unknown");
return String(report.turn);
});
</script>
<span class="turn" data-testid="turn-counter" data-turn={display}>
{i18n.t("game.shell.turn_label")}&nbsp;{display}
</span>
<style>
.turn {
font-size: 0.95rem;
color: #ddd;
white-space: nowrap;
}
</style>
+7 -6
View File
@@ -83,9 +83,8 @@ const en = {
"lobby.error.internal_error": "internal server error",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "race ?",
"game.shell.turn_label": "turn",
"game.shell.turn_unknown": "?",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, turn {turn}",
"game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…",
"game.shell.connection.offline": "offline",
@@ -120,8 +119,11 @@ const en = {
"game.sidebar.empty.inspector": "select an object on the map",
"game.sidebar.empty.order": "order is empty",
"game.sidebar.order.command_delete": "delete",
"game.sidebar.order.submit": "submit",
"game.sidebar.order.submit_in_flight": "submitting…",
"game.sidebar.order.sync.idle": "no changes yet",
"game.sidebar.order.sync.in_flight": "syncing…",
"game.sidebar.order.sync.synced": "synced with server",
"game.sidebar.order.sync.error": "sync failed: {message}",
"game.sidebar.order.sync.retry": "retry",
"game.sidebar.order.status.draft": "draft",
"game.sidebar.order.status.valid": "valid",
"game.sidebar.order.status.invalid": "invalid",
@@ -130,7 +132,6 @@ const en = {
"game.sidebar.order.status.rejected": "rejected",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
"game.sidebar.order.error.batch_failed": "submit failed: {message}",
"game.bottom_tabs.map": "map",
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
+7 -6
View File
@@ -84,9 +84,8 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "раса ?",
"game.shell.turn_label": "ход",
"game.shell.turn_unknown": "?",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, ход {turn}",
"game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…",
"game.shell.connection.offline": "офлайн",
@@ -121,8 +120,11 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.empty.inspector": "выберите объект на карте",
"game.sidebar.empty.order": "приказ пуст",
"game.sidebar.order.command_delete": "удалить",
"game.sidebar.order.submit": "отправить",
"game.sidebar.order.submit_in_flight": "отправка…",
"game.sidebar.order.sync.idle": "нет изменений",
"game.sidebar.order.sync.in_flight": "синхронизация…",
"game.sidebar.order.sync.synced": "сохранено на сервере",
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}",
"game.sidebar.order.sync.retry": "повторить",
"game.sidebar.order.status.draft": "черновик",
"game.sidebar.order.status.valid": "готова",
"game.sidebar.order.status.invalid": "ошибка",
@@ -131,7 +133,6 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.status.rejected": "отклонена",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
"game.sidebar.order.error.batch_failed": "ошибка отправки: {message}",
"game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
+62 -120
View File
@@ -1,19 +1,18 @@
<!--
Order composer tool. Resolves the per-game `OrderDraftStore`,
`GameStateStore`, and `GalaxyClient` from context (all set by
`routes/games/[id]/+layout.svelte`) and renders the local draft as
a vertical list with per-row status, a delete button, and a Submit
button at the bottom.
Order composer tool. Resolves the per-game `OrderDraftStore` from
context (set by `routes/games/[id]/+layout.svelte`) and renders the
local draft as a vertical list with per-row status and a delete
button.
Phase 14 wires the first end-to-end command: clicking Submit calls
`submitOrder` for every entry in `valid` status, flips the in-flight
rows to `submitting`, then merges the per-command verdict back into
the draft once the gateway responds. The optimistic overlay in
`renderedReport` continues to show the player's intent while the
order is in flight, so the inspector and the map reflect the new
name even before the server applies it at turn cutoff.
Phase 14 wires the auto-sync pipeline directly into the draft
store: every successful `add` / `remove` / `move` triggers a
`submitOrder` call so the server always mirrors the local draft.
This view shows the resulting per-command status (`valid`,
`submitting`, `applied`, `rejected`) and a small status bar at the
bottom that surfaces the latest sync result. The earlier explicit
Submit button is gone — there is no separate "send" step anymore.
Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
(Playwright) and via direct store / mocked-client construction
(Vitest).
-->
@@ -24,26 +23,11 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
type GalaxyClientHandle,
} from "$lib/galaxy-client-context.svelte";
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
import { submitOrder } from "../../sync/submit";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const galaxyClient = getContext<GalaxyClientHandle | undefined>(
GALAXY_CLIENT_CONTEXT_KEY,
);
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
draft: "game.sidebar.order.status.draft",
@@ -54,30 +38,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
rejected: "game.sidebar.order.status.rejected",
};
let submitInFlight = $state(false);
let submitError = $state<string | null>(null);
const submittable = $derived.by(() => {
if (draft === undefined) return [] as OrderCommand[];
return draft.commands.filter(
(cmd) => draft.statuses[cmd.id] === "valid",
);
});
const hasInvalid = $derived.by(() => {
if (draft === undefined) return false;
return draft.commands.some((cmd) => draft.statuses[cmd.id] === "invalid");
});
const submitDisabled = $derived(
draft === undefined ||
galaxyClient === undefined ||
galaxyClient.client === null ||
submitInFlight ||
submittable.length === 0 ||
hasInvalid,
);
function describe(cmd: OrderCommand): string {
switch (cmd.kind) {
case "placeholder":
@@ -95,50 +55,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft";
}
async function submit(): Promise<void> {
if (
draft === undefined ||
galaxyClient === undefined ||
galaxyClient.client === null ||
gameState === undefined
)
return;
if (submittable.length === 0 || hasInvalid) return;
const ids = submittable.map((cmd) => cmd.id);
const snapshot = submittable.slice();
submitInFlight = true;
submitError = null;
draft.markSubmitting(ids);
try {
const result = await submitOrder(
galaxyClient.client,
gameState.gameId,
snapshot,
{ updatedAt: draft.updatedAt },
);
if (result.ok) {
draft.applyResults({
results: result.results,
updatedAt: result.updatedAt,
});
if (gameState !== undefined) {
await gameState.refresh();
}
} else {
draft.markRejected(ids);
submitError = i18n.t("game.sidebar.order.error.batch_failed", {
message: result.message,
});
}
} catch (err) {
draft.revertSubmittingToValid();
submitError =
err instanceof Error ? err.message : "submit failed";
} finally {
submitInFlight = false;
}
}
</script>
<section class="tool" data-testid="sidebar-tool-order">
@@ -177,20 +93,37 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
</li>
{/each}
</ol>
{/if}
{#if draft !== undefined}
<div
class="sync sync-{draft.syncStatus}"
data-testid="order-sync"
data-sync-status={draft.syncStatus}
>
<span class="sync-label">
{#if draft.syncStatus === "syncing"}
{i18n.t("game.sidebar.order.sync.in_flight")}
{:else if draft.syncStatus === "synced"}
{i18n.t("game.sidebar.order.sync.synced")}
{:else if draft.syncStatus === "error"}
{i18n.t("game.sidebar.order.sync.error", {
message: draft.syncError ?? "",
})}
{:else}
{i18n.t("game.sidebar.order.sync.idle")}
{/if}
</span>
{#if draft.syncStatus === "error"}
<button
type="button"
class="submit"
data-testid="order-submit"
disabled={submitDisabled}
onclick={() => void submit()}
class="sync-retry"
data-testid="order-sync-retry"
onclick={() => draft.forceSync()}
>
{submitInFlight
? i18n.t("game.sidebar.order.submit_in_flight")
: i18n.t("game.sidebar.order.submit")}
{i18n.t("game.sidebar.order.sync.retry")}
</button>
{#if submitError !== null}
<p class="error" data-testid="order-submit-error">{submitError}</p>
{/if}
</div>
{/if}
</section>
@@ -274,26 +207,35 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
color: #e8eaf6;
border-color: #6d8cff;
}
.submit {
.sync {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.8rem;
color: #aab;
}
.sync-error {
color: #d97a7a;
}
.sync-synced {
color: #8be9a3;
}
.sync-syncing {
color: #6d8cff;
}
.sync-retry {
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 1rem;
background: #1d2440;
color: #e8eaf6;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.submit:not(:disabled):hover {
.sync-retry:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.submit:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.error {
margin: 0.5rem 0 0;
color: #d97a7a;
font-size: 0.85rem;
}
</style>
@@ -164,12 +164,17 @@ fresh.
orderDraft.init({ cache, gameId }),
]);
galaxyClient.set(client);
if (orderDraft.needsServerHydration) {
orderDraft.bindClient(client);
// The server is always polled at game boot — its
// stored order may be fresher than the local cache
// (e.g. user is on a new device), and an offline
// edit must catch up at re-sync time. The hydration
// is non-fatal: a network error keeps the local
// cache and surfaces through `draft.syncStatus`.
await orderDraft.hydrateFromServer({
client,
turn: gameState.currentTurn,
});
}
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
+197 -76
View File
@@ -6,11 +6,16 @@
// Draft state is persisted into the platform `Cache` under the
// `order-drafts` namespace with a per-game key, so a reload, a
// browser restart, or a navigation through the lobby and back into
// the same game restores the previously composed list. Phase 14
// will add the submit pipeline that drains the draft to the server;
// Phase 26 will hide the order tab in history mode through a flag
// passed by the layout (the store itself remains alive across that
// transition so the draft survives history-mode round-trips).
// the same game restores the previously composed list.
//
// Phase 14 wires the auto-sync pipeline: every successful mutation
// (`add` / `remove` / `move`) coalesces a `submitOrder` call so the
// server always mirrors the local draft. The Submit button is gone —
// the player's intent is the source of truth and the engine is kept
// in lock-step. Phase 26 will hide the order tab in history mode
// through a flag passed by the layout (the store itself remains
// alive across that transition so the draft survives history-mode
// round-trips).
//
// The store deliberately carries no Svelte component imports so it
// can be tested directly with a synthetic `Cache` without rendering
@@ -20,6 +25,7 @@ import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import type { CommandStatus, OrderCommand } from "./order-types";
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name";
const NAMESPACE = "order-drafts";
@@ -35,6 +41,8 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error";
export type SyncStatus = "idle" | "syncing" | "synced" | "error";
export class OrderDraftStore {
commands: OrderCommand[] = $state([]);
statuses: Record<string, CommandStatus> = $state({});
@@ -43,18 +51,26 @@ export class OrderDraftStore {
error: string | null = $state(null);
/**
* needsServerHydration is `true` when the cache row for this game
* was absent at `init` time. The layout reads it after both
* `gameState.init` and `orderDraft.init` resolve and, if `true`,
* calls `hydrateFromServer` once the current turn is known.
* An explicitly empty cache row sets it to `false` (the user has
* an empty draft, not a missing one).
* syncStatus reflects the auto-sync pipeline state for the order
* tab status bar:
* - `idle` — no sync attempted yet (e.g. fresh draft after
* hydration or before the first mutation).
* - `syncing` — a `submitOrder` call is in flight.
* - `synced` — the last sync succeeded; statuses match the
* server's view.
* - `error` — the last sync failed (network or non-`ok`); the
* next mutation triggers a retry, or the user can
* force a re-sync via `forceSync`.
*/
needsServerHydration = $state(false);
syncStatus: SyncStatus = $state("idle");
syncError: string | null = $state(null);
private cache: Cache | null = null;
private gameId = "";
private destroyed = false;
private client: GalaxyClient | null = null;
private syncing: Promise<void> | null = null;
private pending = false;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
@@ -63,11 +79,11 @@ export class OrderDraftStore {
* constructs a fresh store per game, so there is no need to support
* mid-life game switching here.
*
* When the cache row is absent, `needsServerHydration` is set to
* `true`; the layout fans out a `hydrateFromServer` call once the
* current turn is known. An explicitly empty cache row is treated
* as "user has an empty draft" and skipped — local intent always
* wins over server snapshot.
* The cache load is the fast path so the order tab paints
* immediately on reopening the game; `hydrateFromServer` (called
* by the layout once the current turn is known) is the
* authoritative read that always overwrites the local cache when
* the server has a stored order.
*/
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
this.cache = opts.cache;
@@ -78,13 +94,7 @@ export class OrderDraftStore {
draftKey(opts.gameId),
);
if (this.destroyed) return;
if (stored === undefined) {
this.commands = [];
this.needsServerHydration = true;
} else {
this.commands = Array.isArray(stored) ? [...stored] : [];
this.needsServerHydration = false;
}
this.recomputeStatuses();
this.status = "ready";
} catch (err) {
@@ -95,37 +105,64 @@ export class OrderDraftStore {
}
/**
* hydrateFromServer fetches the player's stored order from the
* gateway when the cache row was absent at boot. The result is
* merged into `commands` and persisted so subsequent reloads
* prefer the cached version. Failures are non-fatal — the draft
* stays empty and the user can keep composing.
* bindClient stores the per-game `GalaxyClient` so subsequent
* mutations can drive the auto-sync pipeline. The layout calls
* this after the boot `Promise.all` resolves and before
* `hydrateFromServer`, so any mutation that lands afterwards goes
* through the network.
*/
bindClient(client: GalaxyClient): void {
this.client = client;
}
/**
* hydrateFromServer issues `user.games.order.get` for the current
* turn and overwrites the local cache with the server's stored
* order. The server is the source of truth: a player who logged
* in from a fresh device must see their existing orders, and a
* cache that's out-of-sync (e.g. a stale browser tab) is
* superseded by the gateway's view. A `found = false` answer
* empties the local draft. Network failures keep the local cache
* intact and surface as `syncStatus = "error"`.
*/
async hydrateFromServer(opts: {
client: GalaxyClient;
turn: number;
}): Promise<void> {
if (this.status !== "ready" || !this.needsServerHydration) return;
this.needsServerHydration = false;
if (this.status !== "ready") return;
this.client = opts.client;
this.syncStatus = "syncing";
this.syncError = null;
try {
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
if (this.destroyed) return;
this.commands = fetched.commands;
this.updatedAt = fetched.updatedAt;
this.recomputeStatuses();
// Server-fetched commands echo cmdApplied=true for entries
// that survived previous turns; keep them as `applied` so
// the overlay continues to project them on the inspector.
const next = { ...this.statuses };
for (const cmd of this.commands) {
if (next[cmd.id] === "valid") {
next[cmd.id] = "applied";
}
}
this.statuses = next;
await this.persist();
this.syncStatus = "synced";
} catch (err) {
if (this.destroyed) return;
console.warn(
"order-draft: server hydration failed; staying on empty draft",
err,
);
this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "fetch failed";
console.warn("order-draft: server hydration failed", err);
}
}
/**
* add appends a command to the end of the draft, runs local
* validation for the new entry, and persists the updated list.
* validation for the new entry, persists the updated list, and
* triggers an auto-sync to keep the server in lock-step.
* Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store.
*/
@@ -134,11 +171,15 @@ export class OrderDraftStore {
this.commands = [...this.commands, command];
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
await this.persist();
this.scheduleSync();
}
/**
* remove drops the command with the given id from the draft and
* persists the result. A miss is a no-op.
* remove drops the command with the given id from the draft,
* persists the result, and triggers an auto-sync. A miss is a
* no-op. Even removing the last command sends an explicit empty
* order to the server so its stored state matches the local one
* (the engine accepts an empty `cmd[]` per the order handler).
*/
async remove(id: string): Promise<void> {
if (this.status !== "ready") return;
@@ -149,13 +190,16 @@ export class OrderDraftStore {
delete nextStatuses[id];
this.statuses = nextStatuses;
await this.persist();
this.scheduleSync();
}
/**
* move relocates the command at `fromIndex` to `toIndex`, shifting
* the intermediate commands. Out-of-range indices and identical
* positions are no-ops; both indices are clamped against the
* current `commands` length.
* current `commands` length. Triggers an auto-sync — the server
* stores commands in submission order and the engine relies on
* that order at turn cutoff.
*/
async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return;
@@ -169,49 +213,137 @@ export class OrderDraftStore {
next.splice(toIndex, 0, picked);
this.commands = next;
await this.persist();
this.scheduleSync();
}
/**
* markSubmitting flips the status of every entry in `ids` to
* `submitting` so the order tab can disable per-row controls and
* show a spinner. The state machine runs `valid → submitting →
* applied | rejected` (see ui/docs/order-composer.md).
* forceSync re-runs the auto-sync without requiring a mutation.
* Used by the order tab's retry-on-error affordance.
*/
markSubmitting(ids: string[]): void {
forceSync(): void {
this.scheduleSync();
}
dispose(): void {
this.destroyed = true;
this.cache = null;
this.client = null;
}
private scheduleSync(): void {
if (this.client === null) return;
if (this.syncing !== null) {
this.pending = true;
return;
}
this.syncing = this.runSync().finally(() => {
this.syncing = null;
});
}
private async runSync(): Promise<void> {
while (true) {
this.pending = false;
const client = this.client;
if (client === null || this.destroyed) return;
// Capture the snapshot up-front: the in-flight request
// reflects the draft as it was when the mutation landed,
// even if the user adds another command before the
// gateway responds.
const snapshot: OrderCommand[] = $state.snapshot(
this.commands,
) as OrderCommand[];
// Auto-sync sends every command the player still has in
// the draft except the locally-invalid ones (we can't
// expect the server to accept a name that fails our own
// validator) and the Phase 12 placeholder. `applied` and
// `rejected` entries are re-sent so the server's stored
// view always mirrors the local one — re-applying an
// already-applied command is idempotent at the engine
// level (the rename ends at the same name).
const submittable = snapshot.filter((cmd) => {
const status = this.statuses[cmd.id];
return status !== "invalid" && status !== "draft";
});
const submittingIds = submittable.map((cmd) => cmd.id);
this.markSubmittingInternal(submittingIds);
this.syncStatus = "syncing";
this.syncError = null;
try {
const result = await submitOrder(
client,
this.gameId,
submittable,
{ updatedAt: this.updatedAt },
);
if (this.destroyed) return;
if (result.ok) {
this.applyResultsInternal(result.results, result.updatedAt);
// Even with `result.ok === true` an individual
// command may have been rejected by the engine
// (e.g. validation passed transcoders but failed
// the in-game rule). Surface that as an error in
// the sync bar so the player notices and can fix
// or remove the offending command.
const anyRejected = Array.from(result.results.values()).some(
(s) => s === "rejected",
);
this.syncStatus = anyRejected ? "error" : "synced";
this.syncError = anyRejected
? "engine rejected one or more commands"
: null;
} else {
this.markRejectedInternal(submittingIds);
this.syncStatus = "error";
this.syncError = result.message;
}
} catch (err) {
if (this.destroyed) return;
this.revertSubmittingToValidInternal();
this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "sync failed";
}
if (!this.pending) return;
}
}
private markSubmittingInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
// `applied` rows stay applied while the wire request is in
// flight — re-sending an already-applied command is a
// no-op idempotent operation, and flipping the badge back
// to `submitting` would flicker the inspector overlay.
if (next[id] === "valid" || next[id] === "rejected") {
next[id] = "submitting";
}
}
this.statuses = next;
}
/**
* applyResults merges the verdict map returned by `submitOrder`
* into the per-command status map. Entries not present in the
* map keep their current status — useful when only a subset of
* commands round-tripped to the server. The engine-assigned
* `updatedAt` is also stashed for the next submit's stale-order
* detection (kept as plumbing only in Phase 14).
*/
applyResults(opts: {
results: Map<string, CommandStatus>;
updatedAt: number;
}): void {
private applyResultsInternal(
results: Map<string, CommandStatus>,
updatedAt: number,
): void {
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
const next = { ...this.statuses };
for (const [id, status] of opts.results.entries()) {
for (const [id, status] of results.entries()) {
// Drop verdicts for commands the user removed while the
// request was in flight — they are no longer in the
// draft, so re-introducing a stale `applied` row would
// confuse the order tab and the overlay.
if (!liveIds.has(id)) continue;
next[id] = status;
}
this.statuses = next;
this.updatedAt = opts.updatedAt;
this.updatedAt = updatedAt;
}
/**
* markRejected switches every supplied id to `rejected`. Used by
* the order tab when `submitOrder` returns `ok: false` — the
* gateway didn't process any command, so the entire batch is
* treated as rejected.
*/
markRejected(ids: string[]): void {
private markRejectedInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
next[id] = "rejected";
@@ -219,13 +351,7 @@ export class OrderDraftStore {
this.statuses = next;
}
/**
* revertSubmittingToValid resets every entry currently in
* `submitting` back to its pre-submit status (typically `valid`).
* Called when the network layer throws an exception so the
* operator can retry without the rows looking stuck mid-flight.
*/
revertSubmittingToValid(): void {
private revertSubmittingToValidInternal(): void {
const next = { ...this.statuses };
for (const cmd of this.commands) {
if (next[cmd.id] === "submitting") {
@@ -235,11 +361,6 @@ export class OrderDraftStore {
this.statuses = next;
}
dispose(): void {
this.destroyed = true;
this.cache = null;
}
private recomputeStatuses(): void {
const next: Record<string, CommandStatus> = {};
for (const cmd of this.commands) {
+2 -2
View File
@@ -177,7 +177,7 @@ test("map view renders the reported turn and planet count from a live report", a
"data-status",
"ready",
);
await expect(page.getByTestId("turn-counter")).toContainText("turn 4");
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 4");
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count",
"4",
@@ -207,7 +207,7 @@ test("zero-planet game renders the empty world without errors", async ({
"data-status",
"ready",
);
await expect(page.getByTestId("turn-counter")).toContainText("turn 0");
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 0");
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count",
"0",
+3 -2
View File
@@ -35,8 +35,9 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
}) => {
await bootShell(page);
await expect(page.getByTestId("game-shell-header")).toBeVisible();
await expect(page.getByTestId("race-name")).toContainText("race ?");
await expect(page.getByTestId("turn-counter")).toContainText("turn");
await expect(page.getByTestId("game-shell-headline")).toContainText(
"turn",
);
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
});
+22 -18
View File
@@ -213,7 +213,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
test("rename a seeded planet, submit, observe overlay + persist after reload", async ({
test("rename a seeded planet auto-syncs and the overlay survives reload", async ({
page,
}, testInfo) => {
test.skip(
@@ -241,32 +241,30 @@ test("rename a seeded planet, submit, observe overlay + persist after reload", a
await input.fill("New-Earth");
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
// Open the order tab and assert the row.
// Overlay applies immediately on `valid` — no Submit click is
// required because the auto-sync pipeline drives the network.
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
"New-Earth",
);
// Open the order tab and assert the row plus the synced status bar.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"New-Earth",
);
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"valid",
);
await orderTool.getByTestId("order-submit").click();
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"applied",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
expect(handle.submittedRenameName).toBe("New-Earth");
// Switch back to the inspector — overlay should reflect the new name.
await page.getByTestId("sidebar-tab-inspector").click();
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
"New-Earth",
);
// Reload: the order draft is persisted; on cache-miss boots the
// hydrate-from-server path takes over. Both round-trips re-apply
// the overlay so the player still sees the renamed planet.
// Reload: the layout always polls user.games.order.get on boot,
// so the overlay is rebuilt from the server's stored order even
// when the local cache was wiped.
await page.reload();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
@@ -303,11 +301,17 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await orderTool.getByTestId("order-submit").click();
// The auto-sync pipeline reaches the server immediately after
// the inline confirm; the rejected verdict surfaces through the
// per-row status badge and the sync bar.
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"rejected",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"error",
);
await page.getByTestId("sidebar-tab-inspector").click();
// Overlay does not apply rejected commands — old name persists.
+59 -11
View File
@@ -1,10 +1,9 @@
// Component tests for the Phase 10 in-game shell header. The header
// composes the static `race ?` placeholder, the placeholder
// turn-counter (Phase 11 wires the live source), the view-menu, and
// the account-menu. The tests assert the placeholder copy, that
// every view-menu entry dispatches `goto` with the right URL, and
// that the Logout entry of the account-menu calls
// `session.signOut("user")`.
// Component tests for the in-game shell header. The header composes
// the headline strip (`<race> @ <game>, turn N`, falling back to `?`
// while the lobby / report calls are in flight), the view-menu, and
// the account-menu. The tests assert the headline copy, that every
// view-menu entry dispatches `goto` with the right URL, and that the
// Logout entry of the account-menu calls `session.signOut("user")`.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -20,6 +19,31 @@ import {
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
import Header from "../src/lib/header/header.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
} from "../src/lib/game-state.svelte";
function withGameState(opts: {
gameName?: string;
race?: string;
turn?: number;
} = {}): Map<unknown, unknown> {
const store = new GameStateStore();
store.gameName = opts.gameName ?? "";
if (opts.race !== undefined || opts.turn !== undefined) {
store.report = {
turn: opts.turn ?? 0,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 0,
planets: [],
race: opts.race ?? "",
};
store.status = "ready";
}
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
}
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
vi.mock("$app/navigation", () => ({
@@ -37,19 +61,43 @@ afterEach(() => {
});
describe("game-shell header", () => {
test("renders the static race / turn placeholders and toggles", () => {
test("renders fall-back placeholders before the lobby / report data lands", () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
context: withGameState(),
});
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?");
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch(
/turn\s+\?/,
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"? @ ?, turn ?",
);
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
});
test("renders the live race / game / turn from GameStateStore", () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({
gameName: "Phase 14",
race: "Federation",
turn: 7,
}),
});
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"Federation @ Phase 14, turn 7",
);
});
test("partial data still falls back gracefully (race known, game unknown)", () => {
const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({ race: "Federation", turn: 3 }),
});
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"Federation @ ?, turn 3",
);
});
test("clicking the sidebar toggle invokes the prop callback", async () => {
const onToggleSidebar = vi.fn();
const ui = render(Header, {
@@ -72,6 +72,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 1000,
planetCount: planets.length,
planets,
race: "",
};
}
@@ -0,0 +1,240 @@
// Test helpers that fabricate `GalaxyClient` stand-ins for the
// auto-sync pipeline. Two flavours:
//
// - `recordingClient` — captures every `submitOrder` call and lets
// the test assert on the order of in-flight payloads. The
// outcome (`ok` / `rejected`) is settable per call so tests can
// simulate retry loops.
// - `fakeFetchClient` — wires a synthetic `user.games.order.get`
// response so `OrderDraftStore.hydrateFromServer` exercises the
// decoder against a populated FBS envelope.
//
// Both helpers live under `tests/helpers/` so they can be reused
// across `order-draft.test.ts`, `inspector-overlay.test.ts`, and
// future Phase 14+ specs.
import { Builder } from "flatbuffers";
import type { GalaxyClient } from "../../src/api/galaxy-client";
import { uuidToHiLo } from "../../src/api/game-state";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../../src/proto/galaxy/fbs/order";
import type { OrderCommand } from "../../src/sync/order-types";
interface RecordedCall {
messageType: string;
commandIds: string[];
}
interface RecordingHandle {
client: GalaxyClient;
calls: RecordedCall[];
setOutcome(outcome: "ok" | "rejected"): void;
waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>;
}
/**
* recordingClient returns a fake GalaxyClient whose `executeCommand`
* decodes the in-flight UserGamesOrder, records the cmd_ids, and
* answers with a synthesised UserGamesOrderResponse where every
* cmdApplied is true (when outcome="ok") or false (when outcome=
* "rejected"). An optional `delayMs` simulates network latency so
* tests can exercise the coalescing path.
*/
export function recordingClient(
gameId: string,
initialOutcome: "ok" | "rejected",
options: { delayMs?: number } = {},
): RecordingHandle {
const calls: RecordedCall[] = [];
let outcome: "ok" | "rejected" = initialOutcome;
let inFlight = 0;
const waiters: (() => void)[] = [];
const client: GalaxyClient = {
async executeCommand(messageType: string, payload: Uint8Array) {
inFlight += 1;
try {
if (options.delayMs !== undefined) {
await new Promise<void>((resolve) =>
setTimeout(resolve, options.delayMs),
);
}
if (messageType === "user.games.order") {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(payload),
);
const length = decoded.commandsLength();
const commandIds: string[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const id = item.cmdId();
if (id !== null) commandIds.push(id);
}
calls.push({ messageType, commandIds });
if (outcome === "ok") {
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
}
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
throw new Error(`unexpected messageType ${messageType}`);
} finally {
inFlight -= 1;
if (inFlight === 0) {
while (waiters.length > 0) {
const wake = waiters.shift();
wake?.();
}
}
}
},
} as unknown as GalaxyClient;
return {
client,
calls,
setOutcome(next: "ok" | "rejected") {
outcome = next;
},
async waitForCalls(n: number) {
while (calls.length < n) {
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
},
async waitForIdle() {
if (inFlight === 0) return;
await new Promise<void>((resolve) => waiters.push(resolve));
},
};
}
/**
* fakeFetchClient returns a GalaxyClient stand-in whose
* `executeCommand` answers a single hard-coded
* UserGamesOrderGetResponse — enough for `hydrateFromServer` to
* decode a realistic payload without standing up a full mock
* gateway.
*/
export function fakeFetchClient(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found = true,
): { client: GalaxyClient } {
const client: GalaxyClient = {
async executeCommand(messageType: string) {
if (messageType !== "user.games.order.get") {
throw new Error(`unexpected messageType ${messageType}`);
}
return {
resultCode: "ok",
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found),
};
},
} as unknown as GalaxyClient;
return { client };
}
function encodeApplied(
gameId: string,
cmdIds: string[],
applied: boolean,
): Uint8Array {
const builder = new Builder(256);
const itemOffsets = cmdIds.map((id) => {
const cmdIdOffset = builder.createString(id);
const nameOffset = builder.createString("ignored");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(0),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addCmdApplied(builder, applied);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrderResponse.createCommandsVector(
builder,
itemOffsets,
);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(Date.now()));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
function encodeOrderGet(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found: boolean,
): Uint8Array {
const builder = new Builder(256);
let orderOffset = 0;
if (found) {
const itemOffsets = commands.map((cmd) => {
if (cmd.kind !== "planetRename") {
throw new Error(`unsupported command kind ${cmd.kind}`);
}
const cmdIdOffset = builder.createString(cmd.id);
const nameOffset = builder.createString(cmd.name);
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(cmd.planetNumber),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
UserGamesOrder.addCommands(builder, commandsVec);
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
}
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, found);
if (orderOffset !== 0) {
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
}
const offset =
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
+52 -120
View File
@@ -7,12 +7,10 @@
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { Builder } from "flatbuffers";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import InspectorTab from "../src/lib/sidebar/inspector-tab.svelte";
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
@@ -29,22 +27,10 @@ import {
RENDERED_REPORT_CONTEXT_KEY,
createRenderedReportSource,
} from "../src/lib/rendered-report.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder,
} from "../src/lib/galaxy-client-context.svelte";
import { i18n } from "../src/lib/i18n/index.svelte";
import { uuidToHiLo, type GameReport, type ReportPlanet } from "../src/api/game-state";
import type { GalaxyClient } from "../src/api/galaxy-client";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB } from "../src/platform/store/idb";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string;
@@ -93,6 +79,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 1000,
planetCount: planets.length,
planets,
race: "",
};
}
@@ -134,30 +121,15 @@ describe("inspector overlay reactivity", () => {
name: "New-Earth",
});
// `valid` does not participate in the overlay — the player
// has not submitted yet, the inspector still shows the
// server-side name.
// `valid` already participates in the overlay (auto-sync may
// not have fired yet, but the player's intent is committed).
expect(draft.statuses[cmdId]).toBe("valid");
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent("Earth");
draft.markSubmitting([cmdId]);
await waitFor(() => {
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
});
draft.applyResults({
results: new Map([[cmdId, "applied"] as const]),
updatedAt: 99,
});
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
// A simulated server refresh that returns the *un-renamed*
// snapshot must not erase the overlay (turn cutoff has not
// run yet, the engine still reports the old name).
@@ -173,13 +145,39 @@ describe("inspector overlay reactivity", () => {
draft.dispose();
});
test("submit through the order tab applies the overlay end-to-end", async () => {
test("auto-sync after add applies the overlay end-to-end", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const cache = new IDBCache(db);
const draft = new OrderDraftStore();
await draft.init({
cache,
gameId: "11111111-2222-3333-4444-555555555555",
await draft.init({ cache, gameId: GAME_ID });
draft.bindClient(handle.client);
const gameState = new GameStateStore();
gameState.gameId = GAME_ID;
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
gameState.status = "ready";
const selection = new SelectionStore();
selection.selectPlanet(7);
const renderedReport = createRenderedReportSource(gameState, draft);
const context = new Map<unknown, unknown>([
[GAME_STATE_CONTEXT_KEY, gameState],
[SELECTION_CONTEXT_KEY, selection],
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
]);
const inspector = render(InspectorTab, { context });
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"Earth",
);
});
const cmdId = "00000000-0000-0000-0000-000000000abc";
await draft.add({
kind: "planetRename",
@@ -187,94 +185,28 @@ describe("inspector overlay reactivity", () => {
planetNumber: 7,
name: "New-Earth",
});
const gameState = new GameStateStore();
gameState.gameId = "11111111-2222-3333-4444-555555555555";
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
gameState.status = "ready";
// Stub refresh to return the *un-renamed* server snapshot —
// the engine has not applied the rename yet (turn cutoff
// pending). The overlay must still show the new name.
gameState.refresh = (async () => {
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
}) as unknown as typeof gameState.refresh;
const selection = new SelectionStore();
selection.selectPlanet(7);
const renderedReport = createRenderedReportSource(gameState, draft);
const responsePayload = (() => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString(cmdId);
const nameOffset = builder.createString("New-Earth");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(7),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addCmdApplied(builder, true);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, [
item,
]);
const [hi, lo] = uuidToHiLo("11111111-2222-3333-4444-555555555555");
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(99));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
})();
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responsePayload,
}));
const clientHolder = new GalaxyClientHolder();
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
const context = new Map<unknown, unknown>([
[GAME_STATE_CONTEXT_KEY, gameState],
[SELECTION_CONTEXT_KEY, selection],
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
]);
const inspector = render(InspectorTab, { context });
const orderTab = render(OrderTab, { context });
// Pre-submit: the inspector still shows the un-renamed snapshot.
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"Earth",
);
});
const submit = orderTab.getByTestId("order-submit");
expect(submit).not.toBeDisabled();
await fireEvent.click(submit);
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(exec).toHaveBeenCalledTimes(1);
// Overlay applies on `valid` immediately — auto-sync hasn't
// landed yet but the player's intent is committed.
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
});
await handle.waitForCalls(1);
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(handle.calls).toHaveLength(1);
expect(handle.calls[0]!.commandIds).toEqual([cmdId]);
// Inspector still shows the new name after auto-sync.
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
draft.dispose();
});
});
const GAME_ID = "11111111-2222-3333-4444-555555555555";
+188 -124
View File
@@ -9,6 +9,7 @@
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { IDBPDatabase } from "idb";
@@ -176,32 +177,6 @@ describe("OrderDraftStore", () => {
reload.dispose();
});
test("absent cache row flips needsServerHydration flag", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
expect(store.needsServerHydration).toBe(true);
store.dispose();
});
test("explicitly empty cache row honours the user's empty draft", async () => {
const seeded = new OrderDraftStore();
await seeded.init({ cache, gameId: GAME_ID });
await seeded.add({
kind: "planetRename",
id: "00000000-0000-0000-0000-000000000001",
planetNumber: 7,
name: "Earth",
});
await seeded.remove("00000000-0000-0000-0000-000000000001");
seeded.dispose();
const reload = new OrderDraftStore();
await reload.init({ cache, gameId: GAME_ID });
expect(reload.needsServerHydration).toBe(false);
expect(reload.commands).toEqual([]);
reload.dispose();
});
test("planetRename validates locally and statuses reflect valid/invalid", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
@@ -222,111 +197,200 @@ describe("OrderDraftStore", () => {
store.dispose();
});
test("markSubmitting / applyResults flip the status map", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(GAME_ID, [
{
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
store.markSubmitting(["id-1"]);
expect(store.statuses["id-1"]).toBe("submitting");
store.applyResults({
results: new Map([["id-1", "applied"] as const]),
updatedAt: 99,
});
expect(store.statuses["id-1"]).toBe("applied");
expect(store.updatedAt).toBe(99);
store.dispose();
});
test("markRejected switches submitting entries to rejected", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
store.markSubmitting(["id-1"]);
store.markRejected(["id-1"]);
expect(store.statuses["id-1"]).toBe("rejected");
store.dispose();
});
test("revertSubmittingToValid restores status after a thrown submit", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
store.markSubmitting(["id-1"]);
store.revertSubmittingToValid();
expect(store.statuses["id-1"]).toBe("valid");
store.dispose();
});
test("hydrateFromServer seeds the draft on a fresh cache", async () => {
const fakeClient = {
executeCommand: async () => {
const { Builder } = await import("flatbuffers");
const { UUID } = await import("../src/proto/galaxy/fbs/common");
const order = await import("../src/proto/galaxy/fbs/order");
const builder = new Builder(128);
const cmdId = builder.createString("hydr-1");
const name = builder.createString("Hydrated");
const inner = order.CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(7),
name,
);
order.CommandItem.startCommandItem(builder);
order.CommandItem.addCmdId(builder, cmdId);
order.CommandItem.addPayloadType(
builder,
order.CommandPayload.CommandPlanetRename,
);
order.CommandItem.addPayload(builder, inner);
const item = order.CommandItem.endCommandItem(builder);
const cmds = order.UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = (await import("../src/api/game-state")).uuidToHiLo(
GAME_ID,
);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
order.UserGamesOrder.startUserGamesOrder(builder);
order.UserGamesOrder.addGameId(builder, gameIdOffset);
order.UserGamesOrder.addUpdatedAt(builder, BigInt(7));
order.UserGamesOrder.addCommands(builder, cmds);
const orderOffset = order.UserGamesOrder.endUserGamesOrder(builder);
order.UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
order.UserGamesOrderGetResponse.addFound(builder, true);
order.UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset =
order.UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(offset);
return {
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
};
id: "hydr-1",
planetNumber: 7,
name: "Hydrated",
},
};
], 7);
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
expect(store.needsServerHydration).toBe(true);
await store.hydrateFromServer({
client: fakeClient as never,
turn: 5,
});
await store.hydrateFromServer({ client, turn: 5 });
expect(store.commands).toHaveLength(1);
expect(store.commands[0]!.id).toBe("hydr-1");
expect(store.updatedAt).toBe(7);
expect(store.needsServerHydration).toBe(false);
expect(store.statuses["hydr-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("hydrate empties the local cache when server returns found=false", async () => {
// First seed a local draft.
const seeded = new OrderDraftStore();
await seeded.init({ cache, gameId: GAME_ID });
await seeded.add({
kind: "planetRename",
id: "stale",
planetNumber: 1,
name: "Stale",
});
seeded.dispose();
const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(GAME_ID, [], 0, false);
const reload = new OrderDraftStore();
await reload.init({ cache, gameId: GAME_ID });
// Local cache shows the stale entry until the server speaks up.
expect(reload.commands).toHaveLength(1);
await reload.hydrateFromServer({ client, turn: 5 });
expect(reload.commands).toEqual([]);
expect(reload.syncStatus).toBe("synced");
reload.dispose();
});
});
describe("OrderDraftStore auto-sync", () => {
test("add triggers submitOrder with the full draft", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(handle.calls).toHaveLength(1);
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("remove of last command sends an empty cmd[] to the server", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await store.remove("id-1");
await handle.waitForCalls(2);
expect(handle.calls[1]!.commandIds).toEqual([]);
expect(store.commands).toEqual([]);
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("rapid mutations coalesce into the latest draft", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok", { delayMs: 10 });
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await store.add({
kind: "planetRename",
id: "id-2",
planetNumber: 2,
name: "Mars",
});
await store.remove("id-1");
// Wait until the store reaches a steady "synced" state. The
// in-flight first call carries [id-1], the coalesced retry
// reflects the post-remove draft.
await waitFor(() => {
expect(store.syncStatus).toBe("synced");
expect(store.statuses["id-2"]).toBe("applied");
});
expect(handle.calls.length).toBeGreaterThanOrEqual(2);
const last = handle.calls[handle.calls.length - 1]!;
expect(last.commandIds).toEqual(["id-2"]);
expect(store.statuses["id-1"]).toBeUndefined();
store.dispose();
});
test("non-ok response marks every in-flight command as rejected", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "rejected");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.statuses["id-1"]).toBe("rejected");
expect(store.syncStatus).toBe("error");
store.dispose();
});
test("forceSync re-runs the pipeline after a previous failure", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "rejected");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("error");
handle.setOutcome("ok");
store.forceSync();
await handle.waitForCalls(2);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("mutations made before bindClient still sync once client is bound", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
// Mutation lands before the client is wired — the layout
// can't always sequence init → bindClient → mutate, e.g.
// when bind happens after a slow `Promise.all`.
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
expect(handle.calls).toHaveLength(0);
store.bindClient(handle.client);
store.forceSync();
await handle.waitForCalls(1);
expect(store.statuses["id-1"]).toBe("applied");
store.dispose();
});
});
+15 -2
View File
@@ -40,6 +40,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 4000,
planetCount: planets.length,
planets,
race: "",
};
}
@@ -83,7 +84,7 @@ describe("applyOrderOverlay", () => {
expect(out.planets[0]!.name).toBe("Pending");
});
test("skips unsubmitted statuses (draft/valid/invalid/rejected)", () => {
test("skips draft / invalid / rejected statuses", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = {
kind: "planetRename",
@@ -91,12 +92,24 @@ describe("applyOrderOverlay", () => {
planetNumber: 1,
name: "Tentative",
};
for (const status of ["draft", "valid", "invalid", "rejected"] as const) {
for (const status of ["draft", "invalid", "rejected"] as const) {
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
expect(out.planets[0]!.name).toBe("Earth");
}
});
test("applies on `valid` so the player sees their committed intent immediately", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = {
kind: "planetRename",
id: "cmd-1",
planetNumber: 1,
name: "Pending-Sync",
};
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "valid" });
expect(out.planets[0]!.name).toBe("Pending-Sync");
});
test("ignores rename for missing planet (visibility lost)", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = {
+103 -147
View File
@@ -1,53 +1,33 @@
// Component coverage for the Phase 14 order-tab submit flow. Drives
// the tab against an in-memory `OrderDraftStore`, a synthetic
// `GalaxyClient`, and a stubbed `GameStateStore.refresh`. Every
// case asserts both the rendered DOM (status badges, button state)
// and the side effect on the draft store (per-command status flips).
// Component coverage for the Phase 14 order tab. The Submit button
// has been retired — every successful `add` / `remove` triggers
// `OrderDraftStore.scheduleSync`, so the tab is mostly a status
// surface. Tests assert the per-row status badge transitions and
// the bottom-bar sync state.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { Builder } from "flatbuffers";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
} from "../src/lib/game-state.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder,
} from "../src/lib/galaxy-client-context.svelte";
import { i18n } from "../src/lib/i18n/index.svelte";
import { uuidToHiLo } from "../src/api/game-state";
import type { GalaxyClient } from "../src/api/galaxy-client";
import type { OrderCommand } from "../src/sync/order-types";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
import { openGalaxyDB } from "../src/platform/store/idb";
import { recordingClient } from "./helpers/fake-order-client";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string;
let cache: Cache;
beforeEach(async () => {
dbName = `galaxy-order-tab-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
i18n.resetForTests("en");
});
@@ -61,162 +41,138 @@ afterEach(async () => {
});
});
interface Setup {
context: Map<unknown, unknown>;
draft: OrderDraftStore;
gameState: GameStateStore;
clientHolder: GalaxyClientHolder;
exec: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
}
function buildResponse(
commands: { id: string; applied: boolean | null; errorCode: number | null }[],
updatedAt: number,
): Uint8Array {
const builder = new Builder(256);
const itemOffsets = commands.map((c) => {
const cmdIdOffset = builder.createString(c.id);
const nameOffset = builder.createString("ignored");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(0),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
if (c.errorCode !== null) {
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
}
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrderResponse.createCommandsVector(
builder,
itemOffsets,
);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(updatedAt));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
async function makeSetup(commands: OrderCommand[]): Promise<Setup> {
async function makeDraft(
commands: OrderCommand[],
): Promise<{ draft: OrderDraftStore; context: Map<unknown, unknown> }> {
const cache = new IDBCache(db);
const draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
for (const cmd of commands) {
await draft.add(cmd);
}
const gameState = new GameStateStore();
gameState.gameId = GAME_ID;
gameState.status = "ready";
const refresh = vi.fn(async () => {});
gameState.refresh = refresh as unknown as typeof gameState.refresh;
const clientHolder = new GalaxyClientHolder();
const exec = vi.fn(async (_messageType: string, _payload: Uint8Array) => ({
resultCode: "ok",
payloadBytes: buildResponse(
commands.map((cmd) => ({
id: cmd.id,
applied: true,
errorCode: null,
})),
17,
),
}));
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[GAME_STATE_CONTEXT_KEY, gameState],
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
]);
return { context, draft, gameState, clientHolder, exec, refresh };
return { draft, context };
}
describe("order-tab", () => {
test("renders the empty state when the draft has no commands", async () => {
const { context } = await makeSetup([]);
const { draft, context } = await makeDraft([]);
const ui = render(OrderTab, { context });
expect(ui.getByTestId("order-empty")).toBeVisible();
expect(ui.queryByTestId("order-submit")).toBeNull();
expect(ui.queryByTestId("order-list")).toBeNull();
// The sync bar still renders so the user can see the
// idle / synced / error transitions.
expect(ui.getByTestId("order-sync")).toBeVisible();
draft.dispose();
});
test("Submit is disabled when every entry is invalid", async () => {
const { context } = await makeSetup([
test("invalid command shows the invalid status badge", async () => {
const { draft, context } = await makeDraft([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "" },
]);
const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit");
expect(submit).toBeDisabled();
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"invalid",
);
draft.dispose();
});
test("Submit posts every valid command and applies returned statuses", async () => {
const { context, draft, exec, refresh } = await makeSetup([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
]);
test("auto-sync flips the row to applied and the sync bar to synced", async () => {
const handle = recordingClient(GAME_ID, "ok");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit");
expect(submit).not.toBeDisabled();
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("valid");
await fireEvent.click(submit);
await waitFor(() => {
expect(draft.statuses["id-1"]).toBe("applied");
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
expect(exec).toHaveBeenCalledTimes(1);
expect(refresh).toHaveBeenCalledTimes(1);
await handle.waitForCalls(1);
await waitFor(() => {
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"applied",
);
});
test("Non-ok response marks every submitting entry as rejected", async () => {
const { context, draft, refresh } = await makeSetup([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
]);
const exec = vi.fn(async () => ({
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({ code: "boom", message: "down" }),
),
}));
const holder = context.get(GALAXY_CLIENT_CONTEXT_KEY) as GalaxyClientHolder;
holder.set({ executeCommand: exec } as unknown as GalaxyClient);
const ui = render(OrderTab, { context });
await fireEvent.click(ui.getByTestId("order-submit"));
await waitFor(() => {
expect(draft.statuses["id-1"]).toBe("rejected");
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
});
expect(refresh).not.toHaveBeenCalled();
expect(ui.getByTestId("order-submit-error")).toHaveTextContent("down");
expect(handle.calls).toHaveLength(1);
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
draft.dispose();
});
test("Already-applied entries do not get re-submitted", async () => {
const { context, draft, exec } = await makeSetup([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
]);
draft.markSubmitting(["id-1"]);
draft.applyResults({
results: new Map([["id-1", "applied"] as const]),
updatedAt: 1,
test("removing the last command sends an empty cmd[] PUT", async () => {
const handle = recordingClient(GAME_ID, "ok");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit");
expect(submit).toBeDisabled();
expect(exec).not.toHaveBeenCalled();
await fireEvent.click(ui.getByTestId("order-command-delete-0"));
await handle.waitForCalls(2);
expect(handle.calls[1]!.commandIds).toEqual([]);
expect(draft.commands).toEqual([]);
await waitFor(() => {
expect(ui.getByTestId("order-empty")).toBeVisible();
});
draft.dispose();
});
test("non-ok response surfaces the sync error and a retry button", async () => {
const handle = recordingClient(GAME_ID, "rejected");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
const ui = render(OrderTab, { context });
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"error",
);
});
expect(ui.getByTestId("order-sync-retry")).toBeVisible();
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"rejected",
);
// Retry the call now that the fixture answers ok.
handle.setOutcome("ok");
await fireEvent.click(ui.getByTestId("order-sync-retry"));
await handle.waitForCalls(2);
await waitFor(() => {
expect(draft.statuses["id-1"]).toBe("applied");
});
await waitFor(() => {
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
});
draft.dispose();
});
});
+1
View File
@@ -19,6 +19,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
mapHeight: 4000,
planetCount: 0,
planets: [],
race: "",
...overrides,
};
}