import { content } from "@deadhead/content"; import type { Dispatch, PlayerState, Ship } from "@deadhead/schema"; import { createRepositionRecord, 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 { formatCredits, formatDuration } from "../src/format"; 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"); }); /** A crewed Courier idle at the Moon with no Moon bay; Mars discovered but out of range. */ const atMoon = () => fleetState({ bays: [{ id: "b1", locationId: "earth" }], locations: [ { id: "earth", discovered: true }, { id: "moon", discovered: true }, { id: "mars", discovered: true }, { id: "ceres-station", discovered: false }, { id: "jupiter-cloud-platform", discovered: false }, ], company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 }, ships: [ ship({ id: "s-idle", name: "Dusty Mule", locationId: "moon", pilotId: "avatar" }), fleetState().ships[1]!, ], }); it("lets an idle ship with no bay where it is deadhead to a discovered location", async () => { const api = fakeApi({ getPlayerState: atMoon }); renderApp(api, "/fleet"); const idle = (await screen.findByText("Dusty Mule")).closest("li")!; await userEvent.click(within(idle).getByRole("button", { name: "Deadhead (fly empty)" })); const picker = screen.getByRole("dialog", { name: "Deadhead Dusty Mule" }); const options = within(picker) .getAllByRole("button") .map((b) => b.getAttribute("aria-label")) .filter((label) => label !== null); expect(options).toEqual(["Earth", "Mars"]); const mars = within(picker).getByRole("button", { name: "Mars" }); expect(mars).toBeDisabled(); expect(mars).toHaveTextContent("Out of range"); const earth = within(picker).getByRole("button", { name: "Earth" }); const record = createRepositionRecord( { id: "r", ship: { id: "s-idle", modelId: "courier", locationId: "moon", inFlight: false }, pilot: { id: "avatar", isAvatar: true, assignedShipId: "s-idle", inFlight: false, skills: {}, }, destinationId: "earth", now: NOW, worldSeed: 1, }, content, ); expect(earth).toBeEnabled(); expect(earth).toHaveTextContent(`${formatCredits(record.fuelCost)} fuel`); expect(earth).toHaveTextContent(formatDuration(record.arrivalAt - record.departureAt)); await userEvent.click(earth); await waitFor(() => expect(api.call).toHaveBeenCalledWith("createDispatch", { kind: "reposition", shipId: "s-idle", pilotId: "avatar", destinationId: "earth", }), ); }); it("does not offer a deadhead 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: "Deadhead (fly empty)" }), ).not.toBeInTheDocument(); }); });