import type { Dispatch, PlayerState, Ship } from "@deadhead/schema"; import { dispatchProgress } from "@deadhead/sim"; import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { fakeApi, renderApp, samplePlayerState } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const flight: Dispatch = { id: "d1", shipId: "s-fly", pilotId: "p2", contract: null, originId: "earth", destinationId: "moon", tutorial: false, departureAt: NOW - 60_000, arrivalAt: NOW + 180_000, distanceMillionKm: 0.39, fuelCost: 50, expectedPayout: 0, seed: 1, status: "in-flight", resolvedAt: null, }; const ship = (overrides: Partial): Ship => ({ id: "s", modelId: "courier", name: "Ship", status: "idle", locationId: "earth", dispatchId: null, lastArrivalAt: 0, pilotId: null, ...overrides, }); function fleetState(overrides: Partial = {}): PlayerState { const base = samplePlayerState({ tutorial: null }); return { ...base, bays: [ { id: "b1", locationId: "earth" }, { id: "b2", locationId: "moon" }, { id: "b3", locationId: "mars" }, ], ships: [ ship({ id: "s-idle", name: "Dusty Mule", pilotId: "avatar" }), ship({ id: "s-fly", name: "Slow Ox", status: "in-flight", locationId: null, dispatchId: "d1", pilotId: "p2", }), ], pilots: [ { ...base.pilots[0]!, assignedShipId: "s-idle" }, { ...base.pilots[0]!, id: "p2", name: "Rae Okafor", isAvatar: false, assignedShipId: "s-fly", status: "in-flight", locationId: null, }, ], activeDispatches: [flight], ...overrides, }; } describe("fleet screen", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("shows an in-flight ship's progress from the sim, and it advances", async () => { renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet"); const bar = await screen.findByRole("progressbar", { name: "Slow Ox progress" }); expect(bar).toHaveAttribute( "aria-valuenow", String(Math.round(dispatchProgress(flight, NOW) * 100)), ); expect(bar).toHaveAttribute("aria-valuenow", "25"); act(() => vi.advanceTimersByTime(60_000)); expect(bar).toHaveAttribute("aria-valuenow", "50"); expect(screen.getByText("Earth → Moon", { exact: false })).toBeInTheDocument(); expect(screen.getByText("2m 00s")).toBeInTheDocument(); }); it("lists each ship with model, location and pilot", async () => { renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet"); const idle = (await screen.findByText("Dusty Mule")).closest("li")!; expect(idle).toHaveTextContent("Courier"); expect(idle).toHaveTextContent("At Earth"); expect(idle).toHaveTextContent("Captain"); }); it("repositions to a destination chosen from the player's other bays only", async () => { const api = fakeApi({ getPlayerState: () => fleetState() }); renderApp(api, "/fleet"); const idle = (await screen.findByText("Dusty Mule")).closest("li")!; await userEvent.click(within(idle).getByRole("button", { name: "Reposition" })); const picker = screen.getByRole("dialog", { name: "Reposition Dusty Mule" }); const options = within(picker) .getAllByRole("button") .map((b) => b.textContent) .filter((t) => t !== "Cancel"); expect(options).toEqual(["Moon", "Mars"]); await userEvent.click(within(picker).getByRole("button", { name: "Moon" })); await waitFor(() => expect(api.call).toHaveBeenCalledWith("createDispatch", { kind: "reposition", shipId: "s-idle", pilotId: "avatar", destinationId: "moon", }), ); }); it("does not offer reposition to in-flight ships", async () => { renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet"); const flying = (await screen.findByText("Slow Ox")).closest("li")!; expect(within(flying).queryByRole("button", { name: "Reposition" })).not.toBeInTheDocument(); }); });