import { content } from "@deadhead/content"; import type { Contract } from "@deadhead/schema"; import { deliveryXp, 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "../src/api/client"; import { useClock } from "../src/clock/clock"; import { formatCredits, formatDuration } from "../src/format"; import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const loaner = content.shipModel("company-courier")!; /** What the sim predicts for the loaner flying this contract now. */ function expected(contract: Contract) { const distance = routeDistance( content.location(contract.originId)!, content.location(contract.destinationId)!, realToGame(NOW, content.config), ); const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config); return { pay: formatCredits(contract.payout), time: formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression), xp: `+${deliveryXp(distance, contract.tier, content.config)}`, }; } describe("tutorial contract board", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("shows pay, trip time and XP side by side for all three contracts, matching the sim", async () => { const board = sampleTutorialBoard(NOW); renderApp( fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board }), "/contracts", ); const items = await screen.findAllByRole("listitem"); expect(items).toHaveLength(3); items.forEach((item, i) => { const want = expected(board.contracts[i]!); expect(within(item).getByLabelText("Pay")).toHaveTextContent(want.pay); expect(within(item).getByLabelText("Trip time")).toHaveTextContent(want.time); expect(within(item).getByLabelText("XP")).toHaveTextContent(want.xp); }); // The heavy run takes longer than the light one. expect(expected(board.contracts[2]!).time).not.toBe(expected(board.contracts[0]!).time); }); it("styles the scripted contract and shows its name", async () => { renderApp( fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => sampleTutorialBoard(NOW, { scriptedMedium: true }), }), "/contracts", ); const items = await screen.findAllByRole("listitem"); expect(items[1]).toHaveAttribute("data-scripted", "true"); expect(items[1]).toHaveTextContent("Unmarked crates"); expect(items[0]).not.toHaveAttribute("data-scripted"); }); it("dispatches the tutorial run with Fly it", async () => { const api = fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => sampleTutorialBoard(NOW), }); renderApp(api, "/contracts"); const [, medium] = await screen.findAllByRole("listitem"); await userEvent.click(within(medium!).getByRole("button", { name: "Fly it" })); await waitFor(() => expect(api.call).toHaveBeenCalledWith("createDispatch", { kind: "tutorial", contractId: "tutorial:earth:1:0:1", }), ); }); it("disables Fly it while a tutorial run is in flight", async () => { renderApp( fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true, runsCompleted: 1, dealAvailable: false, }, }), getTutorialBoard: () => sampleTutorialBoard(NOW), }), "/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 () => { renderApp( fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => sampleTutorialBoard(NOW), createDispatch: () => { throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying."); }, }), "/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.", ); }); }); describe("scripted delivery summary", () => { it("shows the scripted contract's flavor message after it resolves", async () => { const flavor = content.scriptedTutorialContracts.find( (s) => s.id === "unmarked-crates", )!.flavorMessage; renderApp( fakeApi({ getPlayerState: () => samplePlayerState({ summaries: [ { dispatchId: "d3", shipId: "loaner", pilotId: "avatar", originId: "earth", destinationId: "moon", tutorial: true, credits: 461, deadhead: false, fuelCost: 0, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", loanRepayment: 0, pilotPay: 0, pilotXp: 10, discoveredLocationId: null, scriptedId: "unmarked-crates", restEndsAt: null, resolvedAt: NOW, }, ], }), acknowledgeSummaries: () => ({ acknowledged: 1 }), }), ); const dialog = await screen.findByRole("dialog", { name: "While you were away" }); expect(dialog).toHaveTextContent(flavor); }); });