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