feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet. pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm. Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store. Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
// Phase 17 end-to-end coverage for the ship-class CRUD flow. Boots
|
||||
// Phase 30 end-to-end coverage for the ship-class CRUD flow. Boots
|
||||
// an authenticated session, mocks the gateway with a single local
|
||||
// planet plus an empty `localShipClass` projection, navigates to
|
||||
// the ship-classes table, opens the designer, fills the form, and
|
||||
// asserts that:
|
||||
// the ship-classes table, opens the sidebar calculator, fills the
|
||||
// design, and asserts that:
|
||||
//
|
||||
// 1. Save adds a `createShipClass` row to the local order draft,
|
||||
// auto-syncs through `user.games.order`, and the new class
|
||||
@@ -254,12 +254,12 @@ async function bootSession(page: Page): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
test("create / list / delete ship class via the table + designer", async ({
|
||||
test("create / list / delete ship class via the table + calculator", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 17 spec covers desktop layout; mobile inherits the same store",
|
||||
"phase 30 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
const handle = await mockGateway(page, { createOutcome: "applied" });
|
||||
@@ -270,19 +270,18 @@ test("create / list / delete ship class via the table + designer", async ({
|
||||
await expect(tableHost).toBeVisible();
|
||||
await expect(page.getByTestId("ship-classes-empty")).toBeVisible();
|
||||
|
||||
// "New" opens the calculator in the sidebar with a fresh design.
|
||||
await page.getByTestId("ship-classes-new").click();
|
||||
await expect(page.getByTestId("active-view-designer-ship-class")).toHaveAttribute(
|
||||
"data-mode",
|
||||
"new",
|
||||
);
|
||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||
await expect(calc).toBeVisible();
|
||||
|
||||
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
|
||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
||||
const save = page.getByTestId("designer-ship-class-save");
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
await calc.getByTestId("calculator-name").fill("Drone");
|
||||
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||
const create = calc.getByTestId("calculator-create");
|
||||
await expect(create).toBeEnabled();
|
||||
await create.click();
|
||||
|
||||
// Returns to the table; the optimistic overlay shows the new class.
|
||||
// The table's optimistic overlay shows the new class.
|
||||
await expect(page.getByTestId("ship-classes-table")).toBeVisible();
|
||||
const row = page.getByTestId("ship-classes-row");
|
||||
await expect(row).toHaveAttribute("data-name", "Drone");
|
||||
@@ -312,38 +311,33 @@ test("create / list / delete ship class via the table + designer", async ({
|
||||
expect(handle.lastRemove?.name).toBe("Drone");
|
||||
});
|
||||
|
||||
test("designer keeps Save disabled while the form is invalid", async ({
|
||||
test("calculator keeps Create disabled while the design is invalid", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 17 spec covers desktop layout; mobile inherits the same store",
|
||||
"phase 30 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
await mockGateway(page, { createOutcome: "applied" });
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
|
||||
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
|
||||
await page.getByTestId("ship-classes-new").click();
|
||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||
const create = calc.getByTestId("calculator-create");
|
||||
|
||||
const save = page.getByTestId("designer-ship-class-save");
|
||||
await expect(save).toBeDisabled();
|
||||
// Empty name + all-zero blocks: Create is disabled.
|
||||
await expect(create).toBeDisabled();
|
||||
|
||||
// Empty name surfaces the entity-name error.
|
||||
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
|
||||
"name cannot be empty",
|
||||
);
|
||||
// Mismatched armament / weapons keeps it disabled (pair rule).
|
||||
await calc.getByTestId("calculator-name").fill("Bad");
|
||||
await calc.getByTestId("calculator-block-armament").fill("1");
|
||||
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||
await expect(create).toBeDisabled();
|
||||
|
||||
// Mismatched armament / weapons triggers the pair rule.
|
||||
await page.getByTestId("designer-ship-class-input-name").fill("Bad");
|
||||
await page.getByTestId("designer-ship-class-input-armament").fill("1");
|
||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
||||
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
|
||||
"armament and weapons must be both zero or both nonzero",
|
||||
);
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
// Filling weapons resolves the pair rule.
|
||||
await page.getByTestId("designer-ship-class-input-weapons").fill("1");
|
||||
await expect(save).toBeEnabled();
|
||||
// Filling weapons resolves the pair rule and enables Create.
|
||||
await calc.getByTestId("calculator-block-weapons").fill("1");
|
||||
await expect(create).toBeEnabled();
|
||||
});
|
||||
|
||||
test("rejected createShipClass keeps the table empty and surfaces the failure", async ({
|
||||
@@ -351,23 +345,24 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 17 spec covers desktop layout; mobile inherits the same store",
|
||||
"phase 30 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
await mockGateway(page, { createOutcome: "rejected" });
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
|
||||
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
|
||||
await page.getByTestId("ship-classes-new").click();
|
||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||
|
||||
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
|
||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
||||
await page.getByTestId("designer-ship-class-save").click();
|
||||
await calc.getByTestId("calculator-name").fill("Drone");
|
||||
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||
await calc.getByTestId("calculator-create").click();
|
||||
|
||||
// Designer's save() calls SvelteKit `goto` to navigate back to
|
||||
// the table. SPA navigation keeps the per-game `OrderDraftStore`
|
||||
// alive so the auto-sync round-trip (which flips the status from
|
||||
// `submitting` to `rejected`) lands while the table is showing.
|
||||
// Order tab carries a `rejected` row; the optimistic overlay
|
||||
// drops the class once the engine answers `cmdApplied=false`.
|
||||
// Create stays in the table active view (the calculator is a
|
||||
// sidebar tool). The per-game OrderDraftStore drives the auto-sync
|
||||
// round-trip, which flips the status to `rejected`; the order tab
|
||||
// carries a `rejected` row and the overlay drops the class once the
|
||||
// engine answers cmdApplied=false.
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
|
||||
Reference in New Issue
Block a user