import { content } from "@deadhead/content"; import type { Contract } from "@deadhead/schema"; import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it } from "vitest"; import { ApiError } from "../src/api/client"; import { useClock } from "../src/clock/clock"; import { formatDuration } from "../src/format"; import { fakeApi, renderApp, samplePlayerState } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const contract = (id: string, mass: number, payout: number): Contract => ({ id, originId: "earth", destinationId: "moon", cargoTypeId: "standard", tier: 0, massTonnes: mass, payout, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: NOW, expiresAt: NOW + 3_600_000, firstContact: false, }); const board = { contracts: [contract("t1", 12, 230), contract("t2", 18, 345)], periodEndsAt: NOW + 3_600_000, }; /** The trip time the sim predicts for the Company Courier loaner, Earth→Moon, departing now. */ function expectedTripTime(now: number, massTonnes: number) { const distance = routeDistance( content.location("earth")!, content.location("moon")!, realToGame(now, content.config), ); const hours = travelTimeGameHours( content.shipModel("company-courier")!, distance, massTonnes, content.config, ); return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression); } describe("tutorial contract board", () => { beforeEach(() => useClock.setState({ offsetMs: 0, synced: true, now: NOW })); it("shows each contract with route, cargo, payout and the sim's trip time", async () => { const api = fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board, }); renderApp(api, "/contracts"); const items = await screen.findAllByRole("listitem"); expect(items).toHaveLength(2); expect(items[0]).toHaveTextContent("Earth → Moon"); expect(items[0]).toHaveTextContent("Standard · 12 t"); expect(items[0]).toHaveTextContent("230 cr"); expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent( expectedTripTime(useClock.getState().now, 12), ); }); it("dispatches the tutorial run with Fly it", async () => { const api = fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board, }); renderApp(api, "/contracts"); const [first] = await screen.findAllByRole("listitem"); await userEvent.click(within(first!).getByRole("button", { name: "Fly it" })); await waitFor(() => expect(api.call).toHaveBeenCalledWith("createDispatch", { kind: "tutorial", contractId: "t1", }), ); }); it("disables Fly it while a tutorial run is in flight", async () => { const api = fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true, runsCompleted: 0, dealAvailable: false, }, }), getTutorialBoard: () => board, }); renderApp(api, "/contracts"); await screen.findAllByRole("listitem"); for (const button of screen.getAllByRole("button", { name: "Fly it" })) { expect(button).toBeDisabled(); } expect(screen.getByText(/Your run is in flight/)).toBeInTheDocument(); }); it("shows the server's message when dispatch fails", async () => { const api = fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board, createDispatch: () => { throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying."); }, }); renderApp(api, "/contracts"); const [first] = await screen.findAllByRole("listitem"); await userEvent.click(within(first!).getByRole("button", { name: "Fly it" })); expect(await screen.findByRole("alert")).toHaveTextContent( "Your tutorial run is still flying.", ); }); });