import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours, tripFuel, pilotShare, } from "@deadhead/sim"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "../src/api/client"; import { useClock } from "../src/clock/clock"; import { formatGameTime } from "../src/clock/GameClock"; import { formatCredits, formatDuration } from "../src/format"; import { formatLocalTime } from "../src/screens/DispatchSheet"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const ship = (id: string, name: string, modelId: string, pilotId: string | null): Ship => ({ id, name, modelId, status: "idle", locationId: "earth", dispatchId: null, lastArrivalAt: 0, ...shipExtras(modelId), pilotId, }); const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pilot => ({ id, name, isAvatar, isVeteran: false, xp: 0, level: 0, skills: {}, skillPointsAvailable: 0, assignedShipId: shipId, status: "idle", locationId: "earth", dutyHours: 0, dutyLimit: 12, resting: false, restEndsAt: null, ...pilotExtras, }); const marsJob: Contract = { id: "c-mars", originId: "earth", destinationId: "mars", cargoTypeId: "standard", goodId: "consumer-goods", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 0, massTonnes: 15, payout: 500_000, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: NOW, expiresAt: NOW + 3_600_000, firstContact: false, }; const state = samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 }, bays: [{ id: "b1", locationId: "earth" }], ships: [ ship("s-courier", "Dusty Mule", "courier", "avatar"), ship("s-freighter", "Slow Ox", "freighter", "p-rae"), ship("s-spare", "Spare Kettle", "courier", null), ], pilots: [ pilot("avatar", "Captain", "s-courier", true), pilot("p-rae", "Rae Okafor", "s-freighter", false), ], }); function api(createDispatch?: () => never) { return fakeApi({ getPlayerState: () => state, getContractBoard: () => ({ contracts: [marsJob], periodEndsAt: NOW + 3_600_000 }), ...(createDispatch ? { createDispatch } : {}), }); } async function openSheet() { await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); return screen.getByRole("dialog"); } const option = (dialog: HTMLElement, shipName: string) => within(dialog).getByText(shipName).closest("li")!; describe("dispatch sheet", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("shows the sim's trip time, fuel, net profit and ETAs for an eligible pair", async () => { renderApp(api(), "/contracts"); const dialog = await openSheet(); const freighter = option(dialog, "Slow Ox"); const model = content.shipModel("freighter")!; const earth = content.location("earth")!; const mars = content.location("mars")!; const tripMs = (travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) * MS_PER_HOUR) / content.config.timeCompression; // The fixtures' tanks were filled at 16 cr/fu (CR-015). const fuel = Math.round(tripFuel(model, earth, mars, content.config) * 16); const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config); expect(within(freighter).getByRole("radio")).toBeEnabled(); expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`); expect(freighter).toHaveTextContent(`Fuel used (at your average price)${formatCredits(fuel)}`); expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`); expect(freighter).toHaveTextContent( `ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`, ); expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`); }); it("lists ineligible pairs disabled, with the reason", async () => { renderApp(api(), "/contracts"); const dialog = await openSheet(); const courier = option(dialog, "Dusty Mule"); expect(within(courier).getByRole("radio")).toBeDisabled(); expect(courier).toHaveTextContent("Out of range"); const spare = option(dialog, "Spare Kettle"); expect(within(spare).getByRole("radio")).toBeDisabled(); expect(spare).toHaveTextContent("No pilot assigned"); }); it("sends the dispatch for the chosen pair on Confirm", async () => { const fake = api(); renderApp(fake, "/contracts"); const dialog = await openSheet(); await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" })); await waitFor(() => expect(fake.call).toHaveBeenCalledWith("createDispatch", { kind: "contract", contractId: "c-mars", shipId: "s-freighter", pilotId: "p-rae", }), ); }); it("shows the server's message when the dispatch is refused", async () => { renderApp( api(() => { throw new ApiError(409, "contract-expired", "This contract has expired."); }), "/contracts", ); const dialog = await openSheet(); await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" })); expect(await within(dialog).findByRole("alert")).toHaveTextContent( "This contract has expired.", ); }); });