import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { screen, 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, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const contract = (overrides: Partial): Contract => ({ id: "c", originId: "earth", destinationId: "moon", cargoTypeId: "standard", goodId: "consumer-goods", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 0, massTonnes: 50, payout: 2000, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: NOW, expiresAt: NOW + 30 * 60_000, firstContact: false, ...overrides, }); const boards: Record = { earth: [ contract({ id: "e1", payout: 1999 }), contract({ id: "e2", destinationId: "mars", cargoTypeId: "heavy", goodId: "machine-parts", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 2, requiredSkill: { skillId: "heavy-cargo", level: 2 }, requiredLongHaulLevel: 1, firstContact: true, payout: 88_000, }), ], moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })], }; const ship = (id: string, overrides: Partial): Ship => ({ id, modelId: "courier", name: id, status: "idle", locationId: "earth", dispatchId: null, pilotId: null, lastArrivalAt: NOW - 60_000, ...shipExtras("courier"), ...overrides, }); /** Only an Earth bay; one crewed idle ship at Earth, two at the Moon (the newest arrival). */ const owner = () => { const base = samplePlayerState(); const crew = (id: string, shipId: string, locationId: string): Pilot => ({ ...base.pilots[0]!, id, isAvatar: id === "avatar", assignedShipId: shipId, locationId, }); return samplePlayerState({ tutorial: null, bays: [{ id: "b1", locationId: "earth" }], ships: [ ship("s1", { locationId: "earth", pilotId: "avatar", lastArrivalAt: NOW - 120_000 }), ship("s2", { locationId: "moon", pilotId: "p2", lastArrivalAt: NOW - 1000 }), ship("s3", { locationId: "moon", pilotId: "p3", lastArrivalAt: NOW - 90_000 }), ship("s4", { status: "in-flight", locationId: null, dispatchId: "d", lastArrivalAt: NOW }), ], pilots: [crew("avatar", "s1", "earth"), crew("p2", "s2", "moon"), crew("p3", "s3", "moon")], }); }; const boardApi = () => fakeApi({ getPlayerState: owner, getContractBoard: ({ locationId }) => ({ contracts: boards[locationId] ?? [], periodEndsAt: NOW + 30 * 60_000, }), }); describe("location contract boards", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("lists every discovered location, none undiscovered, with ready-ship badges", async () => { renderApp(boardApi(), "/contracts"); const group = await screen.findByRole("group", { name: "Location" }); const names = within(group) .getAllByRole("button") .map((b) => b.textContent?.replace(/[0-9]+$/, "")); expect(names).toEqual(["Earth", "Moon"]); expect(within(group).getByRole("button", { name: /Earth/ })).toContainElement( within(group).getByLabelText("1 ship ready"), ); expect(within(group).getByRole("button", { name: /Moon/ })).toContainElement( within(group).getByLabelText("2 ships ready"), ); }); it("opens on the most recently arrived idle ship's location, and switches on tap", async () => { renderApp(boardApi(), "/contracts"); expect(await screen.findByText("1,234 cr")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /Earth/ })); expect(await screen.findByText("1,999 cr")).toBeInTheDocument(); expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument(); }); it("defaults to Earth when no ship is idle", async () => { const api = fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }), getContractBoard: ({ locationId }) => ({ contracts: boards[locationId] ?? [], periodEndsAt: NOW + 30 * 60_000, }), }); renderApp(api, "/contracts"); expect(await screen.findByText("1,999 cr")).toBeInTheDocument(); expect(screen.getByText(/No idle ships here/)).toBeInTheDocument(); }); it("opens the location named in the address", async () => { renderApp(boardApi(), "/contracts?location=earth"); expect(await screen.findByText("1,999 cr")).toBeInTheDocument(); }); it("'Find work here' on the Fleet opens that ship's location board", async () => { renderApp(boardApi(), "/fleet"); const row = (await screen.findByText("s1")).closest("li")!; await userEvent.click(within(row).getByRole("link", { name: "Find work here" })); expect(await screen.findByText("1,999 cr")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument(); }); it("shows the good, class and tier, shipper, mass, requirements, expiry and first contact", async () => { renderApp(boardApi(), "/contracts?location=earth"); await screen.findByText("1,999 cr"); const items = screen.getAllByRole("listitem"); const [plain, special] = items; // CR-007 card: good headline, class chip, shipper · mass · distance. expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument(); expect(plain).toHaveTextContent("Standard"); expect(plain).toHaveTextContent("Pacific Orbital Logistics · 50 t"); expect(plain).toHaveTextContent("No skills needed"); expect(plain).toHaveTextContent("expires in 30m 00s"); expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument(); expect(special).toHaveTextContent("Earth → Mars"); expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument(); expect(special).toHaveTextContent("Heavy · T2"); expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1"); expect(special).toHaveTextContent(/M km/); expect(within(special!).getByText("First contact")).toBeInTheDocument(); }); });