ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
7 changed files with 332 additions and 77 deletions
Showing only changes of commit ac14eaff10 - Show all commits
+31 -6
View File
@@ -2160,12 +2160,14 @@ Artifacts:
payload carries the *target* group UUID (the source group, or
the freshly-minted `newGroupId` when an implicit split precedes
the action)
- `Send` action picks destination through a planet picker filtered
by the group's reach (`localPlayerDrive * 40`, computed inline
via the existing `torusShortestDelta` from
`cargo-routes.svelte`); the player's tech levels are already on
`GameReport.localPlayer*` from Phase 18, no extra plumbing
needed
- `Send` action drops the inspector straight into map-pick mode
on click and only mounts the form (ship count + confirm) after
the player chooses a destination — there is no destination
control inside the form. The picker is filtered by the group's
reach (`localPlayerDrive * 40`, computed inline via the existing
`torusShortestDelta` from `cargo-routes.svelte`); the player's
tech levels are already on `GameReport.localPlayer*` from
Phase 18, no extra plumbing needed
- `Modernize` cost preview through `core.blockUpgradeCost`
(Phase 20 bridge), summed over the four ship-class blocks for
the targeted ship count; preview hides when `Core` is not yet
@@ -2175,6 +2177,14 @@ Artifacts:
planet with colonists onboard (engine reference
`controller/ship_group.go:177-179` — `UnloadColonists` is not
called over a foreign planet, so the cargo is lost)
- destructive-command lock: a `Modernize` / `Dismantle` /
`Transfer` order in the draft for a given group disables every
action button on that group's inspector and surfaces a banner
pointing to the order list. Cancelling the queued command in
the order tab releases the lock. Other commands (Send / Load /
Unload / Split / JoinFleet) do not lock — Send is naturally
followed by an out-of-orbit state at turn cutoff and the
remaining four can stack legitimately
- `pkg/calc/ship.go.BlockUpgradeCost` (migrated from
`game/internal/controller/ship_group_upgrade.go`) — the bridge
rule says `ui/core/calc/` only wraps `pkg/calc/` formulas, so
@@ -2262,6 +2272,21 @@ Decisions during stage:
at `newId`. JoinFleet and Split do not get a counter (JoinFleet
is whole-group atomically per the engine; Split *is* the break
command).
6. **Send is pick-first, form-second**. Click → enter map-pick
mode immediately. The form (ship count + confirm) only appears
after a destination is chosen; cancelling the picker leaves no
form behind. Removing the destination control from the form
keeps the surface to one editable field at any time.
7. **Destructive-command lock**. Any `upgradeShipGroup`,
`dismantleShipGroup`, or `transferShipGroup` in the draft for a
given group id disables every action button on that group's
inspector with a "command pending" tooltip and renders a
banner pointing the player at the order list. Cancellation
from the order tab releases the lock. The three commands all
change the group's engine-side state at turn cutoff
(`StateUpgrade` / removal / `StateTransfer`), so any second
action would race the engine's pre-condition check anyway —
the lock surfaces that commitment up-front.
## Phase 21. Sciences — CRUD List + Designer
+32 -1
View File
@@ -61,7 +61,11 @@ every action with `ships are busy ({state})`. Per-action gates:
pre-filters destinations by reach
(`localPlayerDrive * 40`), so a valid pick is always within
range. With no reachable planet, the action is disabled with
the "no planets in drive range" tooltip.
the "no planets in drive range" tooltip. Click drops the
inspector straight into map-pick mode; the form (ship count +
confirm) appears only after the player chooses a destination —
there is no destination control inside the form, so cancelling
the picker leaves the inspector untouched.
- **Load**: requires the orbit planet to be owned by the player
or unowned (`controller/ship_group.go:215`) and the ship class
to have a cargo block (`shipGroupLoad:220`). The dropdown is
@@ -88,6 +92,33 @@ every action with `ships are busy ({state})`. Per-action gates:
in the same orbit (`fleet.go:135-137`); creating a new fleet
always works.
## Destructive-command lock
`Modernize`, `Dismantle`, and `Transfer` are *state-changing* at
turn cutoff: the engine moves the group into `StateUpgrade`,
removes it, or marks it `StateTransfer` respectively. Issuing a
follow-up action against the same group during the same draft
window would race the engine's pre-condition check, so the
inspector locks the group as soon as one of the three commands
lands in the draft for that `groupId`:
- every action button on the group's inspector becomes disabled
with the "an order is already queued" tooltip;
- a banner above the buttons row names the queued command
(modernize / dismantle / transfer) and tells the player to
cancel it in the order list to issue something else;
- removing the queued entry from the order tab releases the lock
on the next render — the derivation watches `draft.commands`
directly.
Send, Load, Unload, Split, and Join Fleet do not lock the group:
Send is naturally followed by an out-of-orbit state at turn
cutoff (the engine's busy check fires next turn anyway), and the
other four can stack legitimately during the same window. The
group continues to appear in the planet inspector's stationed-
ship list while locked — the player can still navigate to the
inspector to read the state and find the order to cancel.
## Modernize cost preview
The form's preview line calls
+5 -1
View File
@@ -308,6 +308,11 @@ const en = {
"game.inspector.ship_group.action.disabled.full_load": "the group is fully loaded",
"game.inspector.ship_group.action.disabled.no_other_races": "no other non-extinct races to transfer to",
"game.inspector.ship_group.action.disabled.unknown_class": "the ship class is missing from the report",
"game.inspector.ship_group.action.disabled.locked": "an order is already queued for this group; cancel it in the order list to issue a new one",
"game.inspector.ship_group.action.locked.banner": "an order is already queued for this group: {command}. Cancel it in the order list to issue another action.",
"game.inspector.ship_group.action.locked.kind.modernize": "modernize",
"game.inspector.ship_group.action.locked.kind.dismantle": "dismantle",
"game.inspector.ship_group.action.locked.kind.transfer": "transfer",
"game.inspector.ship_group.action.field.ships": "ships ({max} total)",
"game.inspector.ship_group.action.field.cargo": "cargo type",
"game.inspector.ship_group.action.field.quantity": "quantity",
@@ -322,7 +327,6 @@ const en = {
"game.inspector.ship_group.action.tech.shields": "shields",
"game.inspector.ship_group.action.tech.cargo": "cargo",
"game.inspector.ship_group.action.send.pick_prompt": "click a planet on the map (Esc to cancel)",
"game.inspector.ship_group.action.send.no_destination": "no destination chosen",
"game.inspector.ship_group.action.modernize.cost": "estimated cost: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "cost preview unavailable",
"game.inspector.ship_group.action.dismantle.warning": "the group is over a foreign planet with colonists aboard — they will die",
+5 -1
View File
@@ -309,6 +309,11 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.action.disabled.full_load": "трюм полностью заполнен",
"game.inspector.ship_group.action.disabled.no_other_races": "нет других нерасправленных рас для передачи",
"game.inspector.ship_group.action.disabled.unknown_class": "класс корабля не найден в отчёте",
"game.inspector.ship_group.action.disabled.locked": "по группе уже отдан приказ; отмените его в списке приказов, чтобы дать новое действие",
"game.inspector.ship_group.action.locked.banner": "по группе уже отдан приказ: {command}. Отмените его в списке приказов, чтобы дать другое действие.",
"game.inspector.ship_group.action.locked.kind.modernize": "модернизация",
"game.inspector.ship_group.action.locked.kind.dismantle": "разборка",
"game.inspector.ship_group.action.locked.kind.transfer": "передача",
"game.inspector.ship_group.action.field.ships": "кораблей (всего {max})",
"game.inspector.ship_group.action.field.cargo": "тип груза",
"game.inspector.ship_group.action.field.quantity": "количество",
@@ -323,7 +328,6 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.action.tech.shields": "защита",
"game.inspector.ship_group.action.tech.cargo": "груз",
"game.inspector.ship_group.action.send.pick_prompt": "выберите планету на карте (Esc — отмена)",
"game.inspector.ship_group.action.send.no_destination": "планета не выбрана",
"game.inspector.ship_group.action.modernize.cost": "ожидаемая стоимость: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "предпросмотр недоступен",
"game.inspector.ship_group.action.dismantle.warning": "группа над чужой планетой везёт колонистов — они погибнут",
@@ -118,6 +118,22 @@ modernize cost preview backed by `core.blockUpgradeCost`.
openForm = null;
});
// Close any open form the moment the group becomes locked
// (a destructive command landed in the draft from elsewhere —
// e.g. another browser tab editing the same draft, or a
// concurrent action on this inspector). Without this guard a
// pending form would still allow Confirm despite the locked
// banner above it.
$effect(() => {
if (pendingDestructiveCommand !== null) {
if (sendPicking) {
pick?.cancel();
sendPicking = false;
}
openForm = null;
}
});
const inOrbit = $derived(group.state === "In_Orbit");
const orbitPlanet = $derived(
inOrbit ? (planets.find((p) => p.number === group.destination) ?? null) : null,
@@ -229,14 +245,58 @@ modernize cost preview backed by `core.blockUpgradeCost`.
return reason === null ? null : i18n.t(reason);
}
const splitDisabledReason = $derived(
!inOrbit
? "game.inspector.ship_group.action.disabled.not_in_orbit"
: group.count < 2
? "game.inspector.ship_group.action.invalid.ship_count"
: null,
// pendingDestructiveCommand watches the order draft for any
// modernize / dismantle / transfer command targeting this group.
// Once the player queues one of those three, every action on the
// group is disabled until the draft entry is removed: each is
// state-changing at turn cutoff (Modernize → state Upgrade,
// Transfer → state Transfer, Dismantle → group removed), so a
// follow-up action would race the engine's pre-condition check
// and noisy-fail server-side. The lock surfaces the commitment
// up-front and points the player at the order list as the way
// to release it.
const pendingDestructiveCommand = $derived.by(() => {
if (draft === undefined) return null;
for (const cmd of draft.commands) {
if (
cmd.kind !== "upgradeShipGroup" &&
cmd.kind !== "dismantleShipGroup" &&
cmd.kind !== "transferShipGroup"
)
continue;
if (cmd.groupId !== group.id) continue;
const status = draft.statuses[cmd.id];
if (status === "rejected" || status === "invalid") continue;
return cmd;
}
return null;
});
const lockedReason: TranslationKey | null = $derived(
pendingDestructiveCommand === null
? null
: "game.inspector.ship_group.action.disabled.locked",
);
const lockedKindLabel = $derived.by(() => {
const cmd = pendingDestructiveCommand;
if (cmd === null) return "";
switch (cmd.kind) {
case "upgradeShipGroup":
return i18n.t("game.inspector.ship_group.action.locked.kind.modernize");
case "dismantleShipGroup":
return i18n.t("game.inspector.ship_group.action.locked.kind.dismantle");
case "transferShipGroup":
return i18n.t("game.inspector.ship_group.action.locked.kind.transfer");
}
});
const splitDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (group.count < 2) return "game.inspector.ship_group.action.invalid.ship_count";
return null;
});
const sendDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (!hasDriveBlock) return "game.inspector.ship_group.action.disabled.no_drive";
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
@@ -244,6 +304,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
return null;
});
const loadDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
@@ -256,6 +317,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
return null;
});
const unloadDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
if (!cargoLoaded) return "game.inspector.ship_group.action.disabled.empty_cargo";
@@ -264,6 +326,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
return null;
});
const modernizeDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
if (!friendlyPlanet) return "game.inspector.ship_group.action.disabled.foreign_planet";
@@ -272,16 +335,19 @@ modernize cost preview backed by `core.blockUpgradeCost`.
return null;
});
const dismantleDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
return null;
});
const transferDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (otherRaces.length === 0)
return "game.inspector.ship_group.action.disabled.no_other_races";
return null;
});
const joinFleetDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
return null;
});
@@ -302,10 +368,42 @@ modernize cost preview backed by `core.blockUpgradeCost`.
closeOthers("split");
splitShips = Math.max(1, Math.min(Math.floor(group.count / 2), group.count - 1));
}
function openSend(): void {
closeOthers("send");
async function openSend(): Promise<void> {
// "Send" is a two-stage flow: the click drops the inspector
// straight into map-pick mode, and the form (ship count +
// confirm) only appears after the player chooses a destination
// or cancels. The destination is therefore never an editable
// control inside the form — picking is the entry point, not a
// sub-step. Re-clicking the action while picking cancels the
// session; re-clicking while the form is open closes it.
if (sendPicking) {
pick?.cancel();
sendPicking = false;
openForm = null;
return;
}
if (openForm === "send") {
openForm = null;
return;
}
openForm = null;
if (pick === undefined || draft === undefined) return;
if (orbitPlanet === null || reachableSet.size === 0) return;
sendShips = group.count;
sendDestination = null;
sendPicking = true;
let picked: number | null = null;
try {
picked = await pick.pick({
sourcePlanetNumber: orbitPlanet.number,
reachableIds: reachableSet,
});
} finally {
sendPicking = false;
}
if (picked === null) return;
sendDestination = picked;
openForm = "send";
}
function openLoad(): void {
closeOthers("load");
@@ -409,21 +507,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
openForm = null;
}
async function startSendPick(): Promise<void> {
if (pick === undefined || sendPicking) return;
if (reachableSet.size === 0 || orbitPlanet === null) return;
sendPicking = true;
try {
const picked = await pick.pick({
sourcePlanetNumber: orbitPlanet.number,
reachableIds: reachableSet,
});
if (picked !== null) sendDestination = picked;
} finally {
sendPicking = false;
}
}
async function confirmSend(): Promise<void> {
if (sendDestination === null || draft === undefined) return;
const ships = clampShips(sendShips);
@@ -592,13 +675,20 @@ modernize cost preview backed by `core.blockUpgradeCost`.
</script>
<section class="actions" data-testid="inspector-ship-group-actions">
{#if pendingDestructiveCommand !== null}
<p class="locked" data-testid="inspector-ship-group-actions-locked">
{i18n.t("game.inspector.ship_group.action.locked.banner", {
command: lockedKindLabel,
})}
</p>
{/if}
<div class="row">
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-split"
disabled={splitDisabledReason !== null || draft === undefined}
title={reasonTooltip(splitDisabledReason as TranslationKey | null) ?? ""}
title={reasonTooltip(splitDisabledReason) ?? ""}
onclick={openSplit}
>
{i18n.t("game.inspector.ship_group.action.split")}
@@ -609,7 +699,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
data-testid="inspector-ship-group-action-send"
disabled={sendDisabledReason !== null || draft === undefined}
title={reasonTooltip(sendDisabledReason) ?? ""}
onclick={openSend}
onclick={() => void openSend()}
>
{i18n.t("game.inspector.ship_group.action.send")}
</button>
@@ -704,8 +794,14 @@ modernize cost preview backed by `core.blockUpgradeCost`.
</form>
{/if}
{#if openForm === "send"}
{#if openForm === "send" && sendDestination !== null}
<form class="form" data-testid="inspector-ship-group-form-send" onsubmit={(e) => { e.preventDefault(); void confirmSend(); }}>
<p class="destination-readonly">
<span class="label">{i18n.t("game.inspector.ship_group.action.field.destination")}</span>
<span data-testid="inspector-ship-group-form-send-destination">
{planets.find((p) => p.number === sendDestination)?.name ?? `#${sendDestination}`}
</span>
</p>
<label>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
@@ -717,26 +813,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
bind:value={sendShips}
/>
</label>
<div class="destination-row">
<span class="label">{i18n.t("game.inspector.ship_group.action.field.destination")}</span>
<span data-testid="inspector-ship-group-form-send-destination">
{#if sendDestination !== null}
{planets.find((p) => p.number === sendDestination)?.name ?? `#${sendDestination}`}
{:else}
{i18n.t("game.inspector.ship_group.action.send.no_destination")}
{/if}
</span>
<button
type="button"
data-testid="inspector-ship-group-form-send-pick"
disabled={sendPicking || pick === undefined}
onclick={() => void startSendPick()}
>
{sendPicking
? i18n.t("game.inspector.ship_group.action.send.pick_prompt")
: i18n.t("game.inspector.ship_group.action.field.destination")}
</button>
</div>
<div class="form-actions">
<button type="button" data-testid="inspector-ship-group-form-send-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
@@ -745,7 +821,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
type="submit"
class="primary"
data-testid="inspector-ship-group-form-send-confirm"
disabled={sendDestination === null}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
@@ -753,6 +828,12 @@ modernize cost preview backed by `core.blockUpgradeCost`.
</form>
{/if}
{#if sendPicking}
<p class="hint" data-testid="inspector-ship-group-form-send-pick-prompt">
{i18n.t("game.inspector.ship_group.action.send.pick_prompt")}
</p>
{/if}
{#if openForm === "load"}
<form class="form" data-testid="inspector-ship-group-form-load" onsubmit={(e) => { e.preventDefault(); void confirmLoad(); }}>
<label>
@@ -1103,14 +1184,15 @@ modernize cost preview backed by `core.blockUpgradeCost`.
border: 1px solid #2a3150;
border-radius: 3px;
}
.form .destination-row {
.form .destination-readonly {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
align-items: baseline;
gap: 0.4rem;
font-size: 0.85rem;
}
.form .destination-row .label {
.form .destination-readonly .label {
color: #aab;
}
.form-actions {
@@ -1146,6 +1228,15 @@ modernize cost preview backed by `core.blockUpgradeCost`.
font-size: 0.85rem;
color: #d9a07a;
}
.locked {
margin: 0;
padding: 0.4rem 0.55rem;
font-size: 0.85rem;
color: #aab;
background: #14182a;
border: 1px solid #2a3150;
border-radius: 3px;
}
.hint {
margin: 0;
font-size: 0.8rem;
+12 -17
View File
@@ -14,18 +14,9 @@ import { expect, test, type Page } from "@playwright/test";
const SESSION_ID = "phase-20-send-session";
interface DebugSurface {
ready?: boolean;
loadSession(): Promise<unknown>;
clearSession?(): Promise<void>;
setDeviceSessionId(id: string): Promise<void>;
}
declare global {
interface Window {
__galaxyDebug?: DebugSurface;
}
}
// `Window.__galaxyDebug` is declared as a global in
// `tests/e2e/storage-keypair-persistence.spec.ts`; reuse that
// declaration so the two specs do not collide on the symbol type.
const SYNTHETIC_FIXTURE = {
turn: 1,
@@ -226,15 +217,19 @@ test("send 2 of 3 ships emits implicit Break + Send into the order draft", async
sidebar.getByTestId("inspector-ship-group-class"),
).toHaveText("Frontier");
// Open Send.
// Click Send: the inspector enters map-pick mode immediately; the
// form (ship count + confirm) only mounts after the destination
// is chosen.
await sidebar.getByTestId("inspector-ship-group-action-send").click();
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
await sendShips.fill("2");
await expect(
sidebar.getByTestId("inspector-ship-group-form-send-pick-prompt"),
).toBeVisible();
// Pick Mars on the map.
await sidebar.getByTestId("inspector-ship-group-form-send-pick").click();
const marsScreen = await projectWorldToScreen(page, 110, 100);
await page.mouse.click(marsScreen.x, marsScreen.y);
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
await sendShips.fill("2");
await expect(
sidebar.getByTestId("inspector-ship-group-form-send-destination"),
).toContainText("Mars");
@@ -262,3 +262,108 @@ describe("ship-group inspector — implicit split + action", () => {
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
});
describe("ship-group inspector — destructive command lock", () => {
const ALL_ACTION_TESTIDS = [
"inspector-ship-group-action-split",
"inspector-ship-group-action-send",
"inspector-ship-group-action-load",
"inspector-ship-group-action-unload",
"inspector-ship-group-action-modernize",
"inspector-ship-group-action-dismantle",
"inspector-ship-group-action-transfer",
"inspector-ship-group-action-join-fleet",
];
test("a queued dismantleShipGroup disables every action with the lock tooltip", async () => {
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
await draft.add({
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId,
});
const ui = mount(localGroup({ id: groupId, count: 3, cargo: "MAT", load: 0.5 }));
const banner = ui.getByTestId("inspector-ship-group-actions-locked");
expect(banner).toHaveTextContent(/dismantle/i);
for (const id of ALL_ACTION_TESTIDS) {
const button = ui.getByTestId(id);
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/order is already queued/i);
}
});
test("a queued upgradeShipGroup locks the inspector and reports modernize as the kind", async () => {
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
await draft.add({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId,
tech: "ALL",
level: 0,
});
const ui = mount(localGroup({ id: groupId, count: 2 }));
expect(ui.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent(
/modernize/i,
);
});
test("a queued transferShipGroup locks the inspector and reports transfer as the kind", async () => {
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
await draft.add({
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId,
acceptor: "Aliens",
});
const ui = mount(localGroup({ id: groupId }));
expect(ui.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent(
/transfer/i,
);
});
test("a queued sendShipGroup does NOT lock the group", async () => {
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
await draft.add({
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId,
destinationPlanetNumber: 99,
});
const ui = mount(localGroup({ id: groupId, count: 3 }));
expect(
ui.queryByTestId("inspector-ship-group-actions-locked"),
).toBeNull();
expect(ui.getByTestId("inspector-ship-group-action-split")).not.toBeDisabled();
});
test("a destructive command targeting a different group does not lock this one", async () => {
await draft.add({
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId: "ffffffff-ffff-ffff-ffff-ffffffffffff",
});
const ui = mount(localGroup({ count: 3 }));
expect(
ui.queryByTestId("inspector-ship-group-actions-locked"),
).toBeNull();
});
test("removing the destructive command from the draft releases the lock", async () => {
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const cmdId = crypto.randomUUID();
await draft.add({
kind: "dismantleShipGroup",
id: cmdId,
groupId,
});
const ui = mount(localGroup({ id: groupId, count: 3 }));
expect(ui.getByTestId("inspector-ship-group-actions-locked")).toBeInTheDocument();
await draft.remove(cmdId);
await waitFor(() => {
expect(
ui.queryByTestId("inspector-ship-group-actions-locked"),
).toBeNull();
});
expect(ui.getByTestId("inspector-ship-group-action-split")).not.toBeDisabled();
});
});