ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
26 changed files with 1144 additions and 728 deletions
Showing only changes of commit 229c43beb5 - Show all commits
+5 -5
View File
@@ -12,7 +12,6 @@ package repo
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"galaxy/model/order" "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 { if err := s.ReadSafe(path, stored); err != nil {
return nil, false, NewStorageError(err) 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{ result := &order.UserGamesOrder{
GameID: stored.GameID, GameID: stored.GameID,
UpdatedAt: stored.UpdatedAt, UpdatedAt: stored.UpdatedAt,
Commands: make([]order.DecodableCommand, len(stored.Commands)), 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 { for i := range stored.Commands {
command, err := ParseOrder(stored.Commands[i], nil) command, err := ParseOrder(stored.Commands[i], nil)
if err != 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) 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) { func CommandResultTest(t *testing.T, o *order.UserGamesOrder) {
assert.NotEmpty(t, o.Commands) assert.NotEmpty(t, o.Commands)
for i := range o.Commands { for i := range o.Commands {
+6 -2
View File
@@ -2,7 +2,6 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -33,7 +32,12 @@ func CommandHandler(c *gin.Context, executor CommandExecutor) {
commands[i] = command commands[i] = command
} }
if len(commands) == 0 { 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 return
} }
+4 -5
View File
@@ -1,7 +1,6 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"galaxy/model/order" "galaxy/model/order"
@@ -18,6 +17,10 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
return 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)) commands := make([]order.DecodableCommand, len(cmd.Commands))
for i := range cmd.Commands { for i := range cmd.Commands {
command, err := repo.ParseOrder(cmd.Commands[i], validateCommand) command, err := repo.ParseOrder(cmd.Commands[i], validateCommand)
@@ -26,10 +29,6 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
} }
commands[i] = command commands[i] = command
} }
if len(commands) == 0 {
errorResponse(c, errors.New("no commands given"))
return
}
result, err := executor.ValidateOrder(cmd.Actor, commands...) result, err := executor.ValidateOrder(cmd.Actor, commands...)
if errorResponse(c, err) { 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) 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{ payload = &rest.Command{
Actor: commandDefaultActor, Actor: commandDefaultActor,
} }
exec := &dummyExecutor{}
emptyRouter := setupRouterExecutor(exec)
w = httptest.NewRecorder() w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload)) 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) { func TestOrderRaceVote(t *testing.T) {
+8 -1
View File
@@ -4,7 +4,14 @@ import "encoding/json"
type Command struct { type Command struct {
Actor string `json:"actor" binding:"notblank"` 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) { func (o Command) MarshalBinary() (data []byte, err error) {
+27 -12
View File
@@ -67,6 +67,12 @@ export interface GameReport {
mapHeight: number; mapHeight: number;
planetCount: number; planetCount: number;
planets: ReportPlanet[]; 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( export async function fetchGameReport(
@@ -189,6 +195,7 @@ function decodeReport(report: Report): GameReport {
mapHeight: report.height(), mapHeight: report.height(),
planetCount: report.planetCount(), planetCount: report.planetCount(),
planets, 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 * applyOrderOverlay returns a copy of `report` with every locally-
* still-in-flight (`submitting`) command from `commands` projected on * valid or still-in-flight or applied command from `commands`
* top. Phase 14 understands `planetRename` only — every other variant * projected on top. Phase 14 understands `planetRename` only —
* passes through. The function is pure: callers re-derive the * every other variant passes through. The function is pure:
* overlay whenever the draft or the report change. * callers re-derive the overlay whenever the draft or the report
* change.
* *
* `statuses` maps command id → status. Entries with `applied` or * `statuses` maps command id → status. Entries with `valid`,
* `submitting` participate in the overlay; everything else (`draft`, * `submitting`, or `applied` participate in the overlay — together
* `valid`, `invalid`, `rejected`) is treated as "not yet committed * they describe "the player's committed intent for this turn":
* by the player" and skipped. This matches the order-composer model: * locally-valid (auto-sync about to fire), in-flight on the wire,
* the player sees their own committed intent, not their unfinished * or acknowledged by the engine. Entries with `draft`, `invalid`,
* edits. * or `rejected` skip the overlay so the player keeps the server's
* (un-renamed) view.
*/ */
export function applyOrderOverlay( export function applyOrderOverlay(
report: GameReport, report: GameReport,
@@ -234,7 +243,13 @@ export function applyOrderOverlay(
let mutatedPlanets: ReportPlanet[] | null = null; let mutatedPlanets: ReportPlanet[] | null = null;
for (const cmd of commands) { for (const cmd of commands) {
const status = statuses[cmd.id]; const status = statuses[cmd.id];
if (status !== "applied" && status !== "submitting") continue; if (
status !== "valid" &&
status !== "submitting" &&
status !== "applied"
) {
continue;
}
if (cmd.kind !== "planetRename") continue; if (cmd.kind !== "planetRename") continue;
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber); const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
if (idx < 0) continue; if (idx < 0) continue;
+8
View File
@@ -37,6 +37,13 @@ type Status = "idle" | "loading" | "ready" | "error";
export class GameStateStore { export class GameStateStore {
gameId: string = $state(""); 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"); status: Status = $state("idle");
report: GameReport | null = $state(null); report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus"); wrapMode: WrapMode = $state("torus");
@@ -95,6 +102,7 @@ export class GameStateStore {
this.error = `game ${gameId} is not in your list`; this.error = `game ${gameId} is not in your list`;
return; return;
} }
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn; this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn); await this.loadTurn(summary.currentTurn);
} catch (err) { } catch (err) {
+47 -10
View File
@@ -1,16 +1,25 @@
<!-- <!--
Top header for the in-game shell. Composes the four artifacts called Top header for the in-game shell. Composes the in-game ID strip
out by `ui/PLAN.md` Phase 10: race name (static placeholder), turn (race name @ game name, turn N), view dropdown / hamburger, and the
counter (static placeholder), view dropdown / hamburger, account account menu. The sidebar-toggle slot to its left appears only on
menu. The sidebar-toggle slot to its left appears only on tablet tablet viewports (7681024 px) and is wired by `+layout.svelte`.
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 The connection-state indicator from the IA section is intentionally
absent until Phase 24 wires push-event state. absent until Phase 24 wires push-event state.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.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 ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte"; import AccountMenu from "./account-menu.svelte";
@@ -20,14 +29,42 @@ absent until Phase 24 wires push-event state.
onToggleSidebar: () => void; onToggleSidebar: () => void;
}; };
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props(); 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> </script>
<header class="game-shell-header" data-testid="game-shell-header"> <header class="game-shell-header" data-testid="game-shell-header">
<div class="left"> <div class="left">
<span class="race" data-testid="race-name"> <span class="headline" data-testid="game-shell-headline">
{i18n.t("game.shell.race_placeholder")} {headline}
</span> </span>
<TurnCounter />
</div> </div>
<div class="right"> <div class="right">
<button <button
@@ -69,7 +106,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem; gap: 0.75rem;
min-width: 0; min-width: 0;
} }
.race { .headline {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; 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.internal_error": "internal server error",
"lobby.error.unknown": "{message}", "lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "race ?", "game.shell.unknown": "?",
"game.shell.turn_label": "turn", "game.shell.headline": "{race} @ {game}, turn {turn}",
"game.shell.turn_unknown": "?",
"game.shell.connection.online": "online", "game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…", "game.shell.connection.reconnecting": "reconnecting…",
"game.shell.connection.offline": "offline", "game.shell.connection.offline": "offline",
@@ -120,8 +119,11 @@ const en = {
"game.sidebar.empty.inspector": "select an object on the map", "game.sidebar.empty.inspector": "select an object on the map",
"game.sidebar.empty.order": "order is empty", "game.sidebar.empty.order": "order is empty",
"game.sidebar.order.command_delete": "delete", "game.sidebar.order.command_delete": "delete",
"game.sidebar.order.submit": "submit", "game.sidebar.order.sync.idle": "no changes yet",
"game.sidebar.order.submit_in_flight": "submitting…", "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.draft": "draft",
"game.sidebar.order.status.valid": "valid", "game.sidebar.order.status.valid": "valid",
"game.sidebar.order.status.invalid": "invalid", "game.sidebar.order.status.invalid": "invalid",
@@ -130,7 +132,6 @@ const en = {
"game.sidebar.order.status.rejected": "rejected", "game.sidebar.order.status.rejected": "rejected",
"game.sidebar.order.label.placeholder": "{label}", "game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}", "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.map": "map",
"game.bottom_tabs.calc": "calc", "game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order", "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.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}", "lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "раса ?", "game.shell.unknown": "?",
"game.shell.turn_label": "ход", "game.shell.headline": "{race} @ {game}, ход {turn}",
"game.shell.turn_unknown": "?",
"game.shell.connection.online": "онлайн", "game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…", "game.shell.connection.reconnecting": "переподключение…",
"game.shell.connection.offline": "офлайн", "game.shell.connection.offline": "офлайн",
@@ -121,8 +120,11 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.empty.inspector": "выберите объект на карте", "game.sidebar.empty.inspector": "выберите объект на карте",
"game.sidebar.empty.order": "приказ пуст", "game.sidebar.empty.order": "приказ пуст",
"game.sidebar.order.command_delete": "удалить", "game.sidebar.order.command_delete": "удалить",
"game.sidebar.order.submit": "отправить", "game.sidebar.order.sync.idle": "нет изменений",
"game.sidebar.order.submit_in_flight": "отправка…", "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.draft": "черновик",
"game.sidebar.order.status.valid": "готова", "game.sidebar.order.status.valid": "готова",
"game.sidebar.order.status.invalid": "ошибка", "game.sidebar.order.status.invalid": "ошибка",
@@ -131,7 +133,6 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.status.rejected": "отклонена", "game.sidebar.order.status.rejected": "отклонена",
"game.sidebar.order.label.placeholder": "{label}", "game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}", "game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
"game.sidebar.order.error.batch_failed": "ошибка отправки: {message}",
"game.bottom_tabs.map": "карта", "game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк", "game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ", "game.bottom_tabs.order": "приказ",
+62 -120
View File
@@ -1,19 +1,18 @@
<!-- <!--
Order composer tool. Resolves the per-game `OrderDraftStore`, Order composer tool. Resolves the per-game `OrderDraftStore` from
`GameStateStore`, and `GalaxyClient` from context (all set by context (set by `routes/games/[id]/+layout.svelte`) and renders the
`routes/games/[id]/+layout.svelte`) and renders the local draft as local draft as a vertical list with per-row status and a delete
a vertical list with per-row status, a delete button, and a Submit button.
button at the bottom.
Phase 14 wires the first end-to-end command: clicking Submit calls Phase 14 wires the auto-sync pipeline directly into the draft
`submitOrder` for every entry in `valid` status, flips the in-flight store: every successful `add` / `remove` / `move` triggers a
rows to `submitting`, then merges the per-command verdict back into `submitOrder` call so the server always mirrors the local draft.
the draft once the gateway responds. The optimistic overlay in This view shows the resulting per-command status (`valid`,
`renderedReport` continues to show the player's intent while the `submitting`, `applied`, `rejected`) and a small status bar at the
order is in flight, so the inspector and the map reflect the new bottom that surfaces the latest sync result. The earlier explicit
name even before the server applies it at turn cutoff. 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 (Playwright) and via direct store / mocked-client construction
(Vitest). (Vitest).
--> -->
@@ -24,26 +23,11 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore, OrderDraftStore,
} from "../../sync/order-draft.svelte"; } 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 type { CommandStatus, OrderCommand } from "../../sync/order-types";
import { submitOrder } from "../../sync/submit";
const draft = getContext<OrderDraftStore | undefined>( const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY, 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> = { const statusKeyMap: Record<CommandStatus, TranslationKey> = {
draft: "game.sidebar.order.status.draft", draft: "game.sidebar.order.status.draft",
@@ -54,30 +38,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
rejected: "game.sidebar.order.status.rejected", 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 { function describe(cmd: OrderCommand): string {
switch (cmd.kind) { switch (cmd.kind) {
case "placeholder": case "placeholder":
@@ -95,50 +55,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
function statusOf(cmd: OrderCommand): CommandStatus { function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft"; 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> </script>
<section class="tool" data-testid="sidebar-tool-order"> <section class="tool" data-testid="sidebar-tool-order">
@@ -177,20 +93,37 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
</li> </li>
{/each} {/each}
</ol> </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 <button
type="button" type="button"
class="submit" class="sync-retry"
data-testid="order-submit" data-testid="order-sync-retry"
disabled={submitDisabled} onclick={() => draft.forceSync()}
onclick={() => void submit()}
> >
{submitInFlight {i18n.t("game.sidebar.order.sync.retry")}
? i18n.t("game.sidebar.order.submit_in_flight")
: i18n.t("game.sidebar.order.submit")}
</button> </button>
{#if submitError !== null}
<p class="error" data-testid="order-submit-error">{submitError}</p>
{/if} {/if}
</div>
{/if} {/if}
</section> </section>
@@ -274,26 +207,35 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
color: #e8eaf6; color: #e8eaf6;
border-color: #6d8cff; 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: inherit;
font-size: 0.9rem; font-size: 0.8rem;
padding: 0.4rem 1rem; padding: 0.15rem 0.5rem;
background: #1d2440; background: transparent;
color: #e8eaf6; color: #aab;
border: 1px solid #2a3150; border: 1px solid #2a3150;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
} }
.submit:not(:disabled):hover { .sync-retry:hover {
color: #e8eaf6;
border-color: #6d8cff; border-color: #6d8cff;
} }
.submit:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.error {
margin: 0.5rem 0 0;
color: #d97a7a;
font-size: 0.85rem;
}
</style> </style>
@@ -164,12 +164,17 @@ fresh.
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId }),
]); ]);
galaxyClient.set(client); 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({ await orderDraft.hydrateFromServer({
client, client,
turn: gameState.currentTurn, turn: gameState.currentTurn,
}); });
}
} catch (err) { } catch (err) {
gameState.failBootstrap(describeBootstrapError(err)); gameState.failBootstrap(describeBootstrapError(err));
} }
+197 -76
View File
@@ -6,11 +6,16 @@
// Draft state is persisted into the platform `Cache` under the // Draft state is persisted into the platform `Cache` under the
// `order-drafts` namespace with a per-game key, so a reload, a // `order-drafts` namespace with a per-game key, so a reload, a
// browser restart, or a navigation through the lobby and back into // browser restart, or a navigation through the lobby and back into
// the same game restores the previously composed list. Phase 14 // the same game restores the previously composed list.
// 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 // Phase 14 wires the auto-sync pipeline: every successful mutation
// passed by the layout (the store itself remains alive across that // (`add` / `remove` / `move`) coalesces a `submitOrder` call so the
// transition so the draft survives history-mode round-trips). // 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 // The store deliberately carries no Svelte component imports so it
// can be tested directly with a synthetic `Cache` without rendering // 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 type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load"; import { fetchOrder } from "./order-load";
import type { CommandStatus, OrderCommand } from "./order-types"; import type { CommandStatus, OrderCommand } from "./order-types";
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name"; import { validateEntityName } from "$lib/util/entity-name";
const NAMESPACE = "order-drafts"; const NAMESPACE = "order-drafts";
@@ -35,6 +41,8 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error"; type Status = "idle" | "ready" | "error";
export type SyncStatus = "idle" | "syncing" | "synced" | "error";
export class OrderDraftStore { export class OrderDraftStore {
commands: OrderCommand[] = $state([]); commands: OrderCommand[] = $state([]);
statuses: Record<string, CommandStatus> = $state({}); statuses: Record<string, CommandStatus> = $state({});
@@ -43,18 +51,26 @@ export class OrderDraftStore {
error: string | null = $state(null); error: string | null = $state(null);
/** /**
* needsServerHydration is `true` when the cache row for this game * syncStatus reflects the auto-sync pipeline state for the order
* was absent at `init` time. The layout reads it after both * tab status bar:
* `gameState.init` and `orderDraft.init` resolve and, if `true`, * - `idle` — no sync attempted yet (e.g. fresh draft after
* calls `hydrateFromServer` once the current turn is known. * hydration or before the first mutation).
* An explicitly empty cache row sets it to `false` (the user has * - `syncing` — a `submitOrder` call is in flight.
* an empty draft, not a missing one). * - `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 cache: Cache | null = null;
private gameId = ""; private gameId = "";
private destroyed = false; 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` * 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 * constructs a fresh store per game, so there is no need to support
* mid-life game switching here. * mid-life game switching here.
* *
* When the cache row is absent, `needsServerHydration` is set to * The cache load is the fast path so the order tab paints
* `true`; the layout fans out a `hydrateFromServer` call once the * immediately on reopening the game; `hydrateFromServer` (called
* current turn is known. An explicitly empty cache row is treated * by the layout once the current turn is known) is the
* as "user has an empty draft" and skipped — local intent always * authoritative read that always overwrites the local cache when
* wins over server snapshot. * the server has a stored order.
*/ */
async init(opts: { cache: Cache; gameId: string }): Promise<void> { async init(opts: { cache: Cache; gameId: string }): Promise<void> {
this.cache = opts.cache; this.cache = opts.cache;
@@ -78,13 +94,7 @@ export class OrderDraftStore {
draftKey(opts.gameId), draftKey(opts.gameId),
); );
if (this.destroyed) return; if (this.destroyed) return;
if (stored === undefined) {
this.commands = [];
this.needsServerHydration = true;
} else {
this.commands = Array.isArray(stored) ? [...stored] : []; this.commands = Array.isArray(stored) ? [...stored] : [];
this.needsServerHydration = false;
}
this.recomputeStatuses(); this.recomputeStatuses();
this.status = "ready"; this.status = "ready";
} catch (err) { } catch (err) {
@@ -95,37 +105,64 @@ export class OrderDraftStore {
} }
/** /**
* hydrateFromServer fetches the player's stored order from the * bindClient stores the per-game `GalaxyClient` so subsequent
* gateway when the cache row was absent at boot. The result is * mutations can drive the auto-sync pipeline. The layout calls
* merged into `commands` and persisted so subsequent reloads * this after the boot `Promise.all` resolves and before
* prefer the cached version. Failures are non-fatal — the draft * `hydrateFromServer`, so any mutation that lands afterwards goes
* stays empty and the user can keep composing. * 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: { async hydrateFromServer(opts: {
client: GalaxyClient; client: GalaxyClient;
turn: number; turn: number;
}): Promise<void> { }): Promise<void> {
if (this.status !== "ready" || !this.needsServerHydration) return; if (this.status !== "ready") return;
this.needsServerHydration = false; this.client = opts.client;
this.syncStatus = "syncing";
this.syncError = null;
try { try {
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn); const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
if (this.destroyed) return; if (this.destroyed) return;
this.commands = fetched.commands; this.commands = fetched.commands;
this.updatedAt = fetched.updatedAt; this.updatedAt = fetched.updatedAt;
this.recomputeStatuses(); 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(); await this.persist();
this.syncStatus = "synced";
} catch (err) { } catch (err) {
if (this.destroyed) return; if (this.destroyed) return;
console.warn( this.syncStatus = "error";
"order-draft: server hydration failed; staying on empty draft", this.syncError = err instanceof Error ? err.message : "fetch failed";
err, console.warn("order-draft: server hydration failed", err);
);
} }
} }
/** /**
* add appends a command to the end of the draft, runs local * 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 * Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store. * always awaits `init` before exposing the store.
*/ */
@@ -134,11 +171,15 @@ export class OrderDraftStore {
this.commands = [...this.commands, command]; this.commands = [...this.commands, command];
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) }; this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
await this.persist(); await this.persist();
this.scheduleSync();
} }
/** /**
* remove drops the command with the given id from the draft and * remove drops the command with the given id from the draft,
* persists the result. A miss is a no-op. * 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> { async remove(id: string): Promise<void> {
if (this.status !== "ready") return; if (this.status !== "ready") return;
@@ -149,13 +190,16 @@ export class OrderDraftStore {
delete nextStatuses[id]; delete nextStatuses[id];
this.statuses = nextStatuses; this.statuses = nextStatuses;
await this.persist(); await this.persist();
this.scheduleSync();
} }
/** /**
* move relocates the command at `fromIndex` to `toIndex`, shifting * move relocates the command at `fromIndex` to `toIndex`, shifting
* the intermediate commands. Out-of-range indices and identical * the intermediate commands. Out-of-range indices and identical
* positions are no-ops; both indices are clamped against the * 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> { async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return; if (this.status !== "ready") return;
@@ -169,49 +213,137 @@ export class OrderDraftStore {
next.splice(toIndex, 0, picked); next.splice(toIndex, 0, picked);
this.commands = next; this.commands = next;
await this.persist(); await this.persist();
this.scheduleSync();
} }
/** /**
* markSubmitting flips the status of every entry in `ids` to * forceSync re-runs the auto-sync without requiring a mutation.
* `submitting` so the order tab can disable per-row controls and * Used by the order tab's retry-on-error affordance.
* show a spinner. The state machine runs `valid → submitting →
* applied | rejected` (see ui/docs/order-composer.md).
*/ */
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 }; const next = { ...this.statuses };
for (const id of ids) { 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"; next[id] = "submitting";
} }
}
this.statuses = next; this.statuses = next;
} }
/** private applyResultsInternal(
* applyResults merges the verdict map returned by `submitOrder` results: Map<string, CommandStatus>,
* into the per-command status map. Entries not present in the updatedAt: number,
* map keep their current status — useful when only a subset of ): void {
* commands round-tripped to the server. The engine-assigned const liveIds = new Set(this.commands.map((cmd) => cmd.id));
* `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 {
const next = { ...this.statuses }; 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; next[id] = status;
} }
this.statuses = next; this.statuses = next;
this.updatedAt = opts.updatedAt; this.updatedAt = updatedAt;
} }
/** private markRejectedInternal(ids: string[]): void {
* 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 {
const next = { ...this.statuses }; const next = { ...this.statuses };
for (const id of ids) { for (const id of ids) {
next[id] = "rejected"; next[id] = "rejected";
@@ -219,13 +351,7 @@ export class OrderDraftStore {
this.statuses = next; this.statuses = next;
} }
/** private revertSubmittingToValidInternal(): void {
* 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 {
const next = { ...this.statuses }; const next = { ...this.statuses };
for (const cmd of this.commands) { for (const cmd of this.commands) {
if (next[cmd.id] === "submitting") { if (next[cmd.id] === "submitting") {
@@ -235,11 +361,6 @@ export class OrderDraftStore {
this.statuses = next; this.statuses = next;
} }
dispose(): void {
this.destroyed = true;
this.cache = null;
}
private recomputeStatuses(): void { private recomputeStatuses(): void {
const next: Record<string, CommandStatus> = {}; const next: Record<string, CommandStatus> = {};
for (const cmd of this.commands) { 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", "data-status",
"ready", "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( await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count", "data-planet-count",
"4", "4",
@@ -207,7 +207,7 @@ test("zero-planet game renders the empty world without errors", async ({
"data-status", "data-status",
"ready", "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( await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count", "data-planet-count",
"0", "0",
+3 -2
View File
@@ -35,8 +35,9 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
}) => { }) => {
await bootShell(page); await bootShell(page);
await expect(page.getByTestId("game-shell-header")).toBeVisible(); await expect(page.getByTestId("game-shell-header")).toBeVisible();
await expect(page.getByTestId("race-name")).toContainText("race ?"); await expect(page.getByTestId("game-shell-headline")).toContainText(
await expect(page.getByTestId("turn-counter")).toContainText("turn"); "turn",
);
await expect(page.getByTestId("view-menu-trigger")).toBeVisible(); await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-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); 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, page,
}, testInfo) => { }, testInfo) => {
test.skip( test.skip(
@@ -241,32 +241,30 @@ test("rename a seeded planet, submit, observe overlay + persist after reload", a
await input.fill("New-Earth"); await input.fill("New-Earth");
await sidebar.getByTestId("inspector-planet-rename-confirm").click(); 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(); await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order"); const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-label-0")).toContainText( await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"New-Earth", "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( await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"applied", "applied",
); );
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
expect(handle.submittedRenameName).toBe("New-Earth"); expect(handle.submittedRenameName).toBe("New-Earth");
// Switch back to the inspector — overlay should reflect the new name. // Reload: the layout always polls user.games.order.get on boot,
await page.getByTestId("sidebar-tab-inspector").click(); // so the overlay is rebuilt from the server's stored order even
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText( // when the local cache was wiped.
"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.
await page.reload(); await page.reload();
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "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(); await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order"); 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( await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"rejected", "rejected",
); );
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"error",
);
await page.getByTestId("sidebar-tab-inspector").click(); await page.getByTestId("sidebar-tab-inspector").click();
// Overlay does not apply rejected commands — old name persists. // 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 // Component tests for the in-game shell header. The header composes
// composes the static `race ?` placeholder, the placeholder // the headline strip (`<race> @ <game>, turn N`, falling back to `?`
// turn-counter (Phase 11 wires the live source), the view-menu, and // while the lobby / report calls are in flight), the view-menu, and
// the account-menu. The tests assert the placeholder copy, that // the account-menu. The tests assert the headline copy, that every
// every view-menu entry dispatches `goto` with the right URL, and // view-menu entry dispatches `goto` with the right URL, and that the
// that the Logout entry of the account-menu calls // Logout entry of the account-menu calls `session.signOut("user")`.
// `session.signOut("user")`.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
@@ -20,6 +19,31 @@ import {
import { i18n } from "../src/lib/i18n/index.svelte"; import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte"; import { session } from "../src/lib/session-store.svelte";
import Header from "../src/lib/header/header.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[]) => {}); const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
vi.mock("$app/navigation", () => ({ vi.mock("$app/navigation", () => ({
@@ -37,19 +61,43 @@ afterEach(() => {
}); });
describe("game-shell header", () => { 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 onToggleSidebar = vi.fn();
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar }, props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
context: withGameState(),
}); });
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?"); expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch( "? @ ?, turn ?",
/turn\s+\?/,
); );
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument(); expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-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 () => { test("clicking the sidebar toggle invokes the prop callback", async () => {
const onToggleSidebar = vi.fn(); const onToggleSidebar = vi.fn();
const ui = render(Header, { const ui = render(Header, {
@@ -72,6 +72,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 1000, mapHeight: 1000,
planetCount: planets.length, planetCount: planets.length,
planets, 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 "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { render, waitFor } from "@testing-library/svelte";
import { Builder } from "flatbuffers"; import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import InspectorTab from "../src/lib/sidebar/inspector-tab.svelte"; import InspectorTab from "../src/lib/sidebar/inspector-tab.svelte";
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
import { import {
GAME_STATE_CONTEXT_KEY, GAME_STATE_CONTEXT_KEY,
GameStateStore, GameStateStore,
@@ -29,22 +27,10 @@ import {
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
createRenderedReportSource, createRenderedReportSource,
} from "../src/lib/rendered-report.svelte"; } 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 { i18n } from "../src/lib/i18n/index.svelte";
import { uuidToHiLo, type GameReport, type ReportPlanet } from "../src/api/game-state"; import type { GameReport, ReportPlanet } from "../src/api/game-state";
import type { GalaxyClient } from "../src/api/galaxy-client";
import { IDBCache } from "../src/platform/store/idb-cache"; import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB } from "../src/platform/store/idb"; 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 db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string; let dbName: string;
@@ -93,6 +79,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 1000, mapHeight: 1000,
planetCount: planets.length, planetCount: planets.length,
planets, planets,
race: "",
}; };
} }
@@ -134,30 +121,15 @@ describe("inspector overlay reactivity", () => {
name: "New-Earth", name: "New-Earth",
}); });
// `valid` does not participate in the overlay — the player // `valid` already participates in the overlay (auto-sync may
// has not submitted yet, the inspector still shows the // not have fired yet, but the player's intent is committed).
// server-side name.
expect(draft.statuses[cmdId]).toBe("valid"); expect(draft.statuses[cmdId]).toBe("valid");
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent("Earth");
draft.markSubmitting([cmdId]);
await waitFor(() => { await waitFor(() => {
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent( expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth", "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* // A simulated server refresh that returns the *un-renamed*
// snapshot must not erase the overlay (turn cutoff has not // snapshot must not erase the overlay (turn cutoff has not
// run yet, the engine still reports the old name). // run yet, the engine still reports the old name).
@@ -173,13 +145,39 @@ describe("inspector overlay reactivity", () => {
draft.dispose(); 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 cache = new IDBCache(db);
const draft = new OrderDraftStore(); const draft = new OrderDraftStore();
await draft.init({ await draft.init({ cache, gameId: GAME_ID });
cache, draft.bindClient(handle.client);
gameId: "11111111-2222-3333-4444-555555555555",
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"; const cmdId = "00000000-0000-0000-0000-000000000abc";
await draft.add({ await draft.add({
kind: "planetRename", kind: "planetRename",
@@ -187,94 +185,28 @@ describe("inspector overlay reactivity", () => {
planetNumber: 7, planetNumber: 7,
name: "New-Earth", name: "New-Earth",
}); });
// Overlay applies on `valid` immediately — auto-sync hasn't
const gameState = new GameStateStore(); // landed yet but the player's intent is committed.
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);
await waitFor(() => { await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent( expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth", "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(); 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 "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { IDBPDatabase } from "idb"; import type { IDBPDatabase } from "idb";
@@ -176,32 +177,6 @@ describe("OrderDraftStore", () => {
reload.dispose(); 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 () => { test("planetRename validates locally and statuses reflect valid/invalid", async () => {
const store = new OrderDraftStore(); const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID }); await store.init({ cache, gameId: GAME_ID });
@@ -222,111 +197,200 @@ describe("OrderDraftStore", () => {
store.dispose(); store.dispose();
}); });
test("markSubmitting / applyResults flip the status map", async () => { test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
const store = new OrderDraftStore(); const { fakeFetchClient } = await import("./helpers/fake-order-client");
await store.init({ cache, gameId: GAME_ID }); const { client } = fakeFetchClient(GAME_ID, [
await store.add({ {
kind: "planetRename", kind: "planetRename",
id: "id-1", id: "hydr-1",
planetNumber: 1, planetNumber: 7,
name: "Earth", name: "Hydrated",
});
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(),
};
}, },
}; ], 7);
const store = new OrderDraftStore(); const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID }); await store.init({ cache, gameId: GAME_ID });
expect(store.needsServerHydration).toBe(true); await store.hydrateFromServer({ client, turn: 5 });
await store.hydrateFromServer({
client: fakeClient as never,
turn: 5,
});
expect(store.commands).toHaveLength(1); expect(store.commands).toHaveLength(1);
expect(store.commands[0]!.id).toBe("hydr-1"); expect(store.commands[0]!.id).toBe("hydr-1");
expect(store.updatedAt).toBe(7); 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(); store.dispose();
}); });
}); });
+15 -2
View File
@@ -40,6 +40,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
mapHeight: 4000, mapHeight: 4000,
planetCount: planets.length, planetCount: planets.length,
planets, planets,
race: "",
}; };
} }
@@ -83,7 +84,7 @@ describe("applyOrderOverlay", () => {
expect(out.planets[0]!.name).toBe("Pending"); 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 report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = { const cmd: OrderCommand = {
kind: "planetRename", kind: "planetRename",
@@ -91,12 +92,24 @@ describe("applyOrderOverlay", () => {
planetNumber: 1, planetNumber: 1,
name: "Tentative", 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 }); const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
expect(out.planets[0]!.name).toBe("Earth"); 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)", () => { test("ignores rename for missing planet (visibility lost)", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = { const cmd: OrderCommand = {
+103 -147
View File
@@ -1,53 +1,33 @@
// Component coverage for the Phase 14 order-tab submit flow. Drives // Component coverage for the Phase 14 order tab. The Submit button
// the tab against an in-memory `OrderDraftStore`, a synthetic // has been retired — every successful `add` / `remove` triggers
// `GalaxyClient`, and a stubbed `GameStateStore.refresh`. Every // `OrderDraftStore.scheduleSync`, so the tab is mostly a status
// case asserts both the rendered DOM (status badges, button state) // surface. Tests assert the per-row status badge transitions and
// and the side effect on the draft store (per-command status flips). // the bottom-bar sync state.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { Builder } from "flatbuffers"; import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import OrderTab from "../src/lib/sidebar/order-tab.svelte"; import OrderTab from "../src/lib/sidebar/order-tab.svelte";
import { import {
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore, OrderDraftStore,
} from "../src/sync/order-draft.svelte"; } 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 { 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 type { OrderCommand } from "../src/sync/order-types";
import { IDBCache } from "../src/platform/store/idb-cache"; import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; import { openGalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index"; import { recordingClient } from "./helpers/fake-order-client";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
const GAME_ID = "11111111-2222-3333-4444-555555555555"; const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: Awaited<ReturnType<typeof openGalaxyDB>>; let db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string; let dbName: string;
let cache: Cache;
beforeEach(async () => { beforeEach(async () => {
dbName = `galaxy-order-tab-${crypto.randomUUID()}`; dbName = `galaxy-order-tab-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName); db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
i18n.resetForTests("en"); i18n.resetForTests("en");
}); });
@@ -61,162 +41,138 @@ afterEach(async () => {
}); });
}); });
interface Setup { async function makeDraft(
context: Map<unknown, unknown>; commands: OrderCommand[],
draft: OrderDraftStore; ): Promise<{ draft: OrderDraftStore; context: Map<unknown, unknown> }> {
gameState: GameStateStore; const cache = new IDBCache(db);
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> {
const draft = new OrderDraftStore(); const draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID }); await draft.init({ cache, gameId: GAME_ID });
for (const cmd of commands) { for (const cmd of commands) {
await draft.add(cmd); 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>([ const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft], [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", () => { describe("order-tab", () => {
test("renders the empty state when the draft has no commands", async () => { 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 }); const ui = render(OrderTab, { context });
expect(ui.getByTestId("order-empty")).toBeVisible(); 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 () => { test("invalid command shows the invalid status badge", async () => {
const { context } = await makeSetup([ const { draft, context } = await makeDraft([
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "" }, { kind: "planetRename", id: "id-1", planetNumber: 1, name: "" },
]); ]);
const ui = render(OrderTab, { context }); const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit");
expect(submit).toBeDisabled();
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent( expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"invalid", "invalid",
); );
draft.dispose();
}); });
test("Submit posts every valid command and applies returned statuses", async () => { test("auto-sync flips the row to applied and the sync bar to synced", async () => {
const { context, draft, exec, refresh } = await makeSetup([ const handle = recordingClient(GAME_ID, "ok");
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" }, const { draft, context } = await makeDraft([]);
]); draft.bindClient(handle.client);
const ui = render(OrderTab, { context }); 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 draft.add({
kind: "planetRename",
await waitFor(() => { id: "id-1",
expect(draft.statuses["id-1"]).toBe("applied"); 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( expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
"applied", "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(() => { await waitFor(() => {
expect(draft.statuses["id-1"]).toBe("rejected"); expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
}); });
expect(refresh).not.toHaveBeenCalled(); expect(handle.calls).toHaveLength(1);
expect(ui.getByTestId("order-submit-error")).toHaveTextContent("down"); expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
draft.dispose();
}); });
test("Already-applied entries do not get re-submitted", async () => { test("removing the last command sends an empty cmd[] PUT", async () => {
const { context, draft, exec } = await makeSetup([ const handle = recordingClient(GAME_ID, "ok");
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" }, const { draft, context } = await makeDraft([]);
]); draft.bindClient(handle.client);
draft.markSubmitting(["id-1"]);
draft.applyResults({ await draft.add({
results: new Map([["id-1", "applied"] as const]), kind: "planetRename",
updatedAt: 1, id: "id-1",
planetNumber: 1,
name: "Earth",
}); });
await handle.waitForCalls(1);
const ui = render(OrderTab, { context }); const ui = render(OrderTab, { context });
const submit = ui.getByTestId("order-submit"); await fireEvent.click(ui.getByTestId("order-command-delete-0"));
expect(submit).toBeDisabled();
expect(exec).not.toHaveBeenCalled(); 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, mapHeight: 4000,
planetCount: 0, planetCount: 0,
planets: [], planets: [],
race: "",
...overrides, ...overrides,
}; };
} }