import { content } from "@deadhead/content"; import type { Dispatch, PlayerState, Ship } from "@deadhead/schema"; 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 { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const ship = (id: string, status: Ship["status"]): Ship => ({ id, modelId: "courier", name: id, status, locationId: status === "idle" ? "earth" : null, dispatchId: status === "idle" ? null : `d-${id}`, lastArrivalAt: 0, ...shipExtras("courier"), pilotId: null, }); const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({ id: `d-${shipId}`, shipId, pilotId: "avatar", contract: null, originId: "earth", destinationId: "moon", tutorial: false, departureAt: NOW - 60_000, arrivalAt, distanceMillionKm: 0.39, fuelCost: 50, fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", resolvedAt: null, }); describe("Dashboard (CR-020)", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("shows idle and in-flight counts and every ship on a job, soonest first", async () => { const state = samplePlayerState({ tutorial: null, ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")], activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)], }); renderApp(fakeApi({ getPlayerState: () => state })); await screen.findByRole("heading", { level: 1, name: "Dashboard" }); const statValue = (label: string) => screen.getByText(label).nextElementSibling; expect(statValue("Ships idle")).toHaveTextContent("1"); expect(statValue("Ships in flight")).toHaveTextContent("2"); const jobs = screen.getByRole("region", { name: "Ships on jobs" }); const rows = within(jobs).getAllByRole("listitem"); expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]); expect(rows[0]).toHaveTextContent("Earth โ†’ Moon1m 05s"); expect(rows[1]).toHaveTextContent("2m 05s"); act(() => vi.advanceTimersByTime(10_000)); expect(rows[0]).toHaveTextContent("55s"); }); it("shows tutorial run progress toward the deal during the tutorial", async () => { const state = samplePlayerState({ tutorial: { loanerLocationId: "moon", runInFlight: false, runsCompleted: 3, dealAvailable: false, }, }); renderApp(fakeApi({ getPlayerState: () => state })); expect(await screen.findByLabelText("Company runs")).toHaveTextContent( `Company runs: 3 of ${content.config.tutorialRunsBeforeDeal}`, ); expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument(); }); }); describe("loan on HQ", () => { const total = content.dealLoanAmount; const owner = (credits: number, loanBalance: number): PlayerState => samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance } }); it("shows the balance and a paid-off progress bar matching state", async () => { renderApp(fakeApi({ getPlayerState: () => owner(2000, total * 0.75) })); expect(await screen.findByLabelText("Loan balance")).toHaveTextContent( `${(total * 0.75).toLocaleString("en-US")} cr left`, ); expect(screen.getByRole("progressbar", { name: "Loan paid off" })).toHaveAttribute( "aria-valuenow", "25", ); }); it("pays down with the entered amount", async () => { const api = fakeApi({ getPlayerState: () => owner(2000, 4000), repayLoan: ({ amount }) => ({ company: { id: "c", name: "D", credits: 2000 - amount, loanBalance: 4000 - amount }, }), }); renderApp(api); await userEvent.type(await screen.findByLabelText("Pay down amount"), "750"); await userEvent.click(screen.getByRole("button", { name: "Pay down" })); await waitFor(() => expect(api.call).toHaveBeenCalledWith("repayLoan", { amount: 750 })); }); it("blocks an amount above the balance", async () => { const api = fakeApi({ getPlayerState: () => owner(5000, 300) }); renderApp(api); await userEvent.type(await screen.findByLabelText("Pay down amount"), "400"); expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled(); expect(screen.getByText("You only owe 300 cr.")).toBeInTheDocument(); expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything()); }); it("blocks an amount above the player's credits", async () => { const api = fakeApi({ getPlayerState: () => owner(200, 3000) }); renderApp(api); await userEvent.type(await screen.findByLabelText("Pay down amount"), "250"); expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled(); expect(screen.getByText("You only have 200 cr.")).toBeInTheDocument(); expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything()); }); it("is hidden once the loan is paid off", async () => { renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) })); await screen.findByRole("heading", { level: 1, name: "Dashboard" }); expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument(); }); it("shows the lender's cut in a delivery summary", async () => { comeBack(); const state: PlayerState = { ...owner(2000, 4000), summaries: [ { dispatchId: "d1", shipId: "s1", pilotId: "avatar", originId: "earth", destinationId: "moon", tutorial: false, credits: 538, deadhead: false, independent: false, fuelCost: 0, topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", loanRepayment: 230, pilotPay: 0, pilotXp: 10, discoveredLocationId: null, scriptedId: null, event: null, restEndsAt: null, resolvedAt: 1, }, ], }; renderApp( fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }), ); const dialog = await screen.findByRole("dialog", { name: /While you were away/ }); // The pop-up shows the net, after the lender's cut; the cut itself is in the Logbook (CR-025). await waitFor(() => expect(dialog).toHaveTextContent("delivered Consumer Goods to Moon, +538 cr"), ); }); }); describe("your ship on the Dashboard (CR-020)", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); const avatar = samplePlayerState().pilots[0]!; const mine = (overrides: Partial = {}): Ship => ({ ...ship("Iron Mule", "idle"), pilotId: "avatar", ...overrides, }); const owner = (overrides: Partial) => samplePlayerState({ tutorial: null, pilots: [{ ...avatar, assignedShipId: "Iron Mule", dutyHours: 3, dutyLimit: 12 }], ...overrides, }); const panel = async () => screen.findByRole("region", { name: "Your ship" }); it("shows an idle ship's place, fuel, the pilot's duty and where to find work", async () => { renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) })); const p = await panel(); expect(p).toHaveTextContent("Iron Mule"); expect(p).toHaveTextContent("Idle"); expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth"); expect(within(p).getByRole("progressbar", { name: "Iron Mule fuel" })).toBeInTheDocument(); expect(within(p).getByRole("progressbar", { name: "Your duty" })).toHaveAttribute( "aria-valuenow", "3", ); expect(within(p).getByRole("link", { name: "Find work here" })).toHaveAttribute( "href", "/contracts?location=earth", ); expect(within(p).queryByLabelText("Your ship low fuel")).not.toBeInTheDocument(); }); it("warns when your ship is low on fuel", async () => { renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine({ fuelFu: 1 })] }) })); expect(await screen.findByLabelText("Your ship low fuel")).toHaveTextContent("Low fuel"); }); it("shows your ship's trip, cargo and arrival in flight", async () => { const flight: Dispatch = { ...dispatch("Iron Mule", NOW + 90_000), contract: { id: "c1", originId: "earth", destinationId: "moon", cargoTypeId: "high-value", goodId: "medical-supplies", shipper: "Meridian Health", priority: false, deliveryWindowGameHours: null, tier: 1, massTonnes: 6, payout: 900, requiredSkill: { skillId: "expensive-cargo", level: 1 }, requiredLongHaulLevel: 0, offeredAt: NOW, expiresAt: NOW + 600_000, firstContact: false, }, }; const flying = mine({ status: "in-flight", locationId: null, dispatchId: flight.id }); renderApp( fakeApi({ getPlayerState: () => owner({ ships: [flying], activeDispatches: [flight] }) }), ); const p = await panel(); expect(p).toHaveTextContent("In flight"); expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("Earth โ†’ Moon"); expect(within(p).getByLabelText("Your cargo")).toHaveTextContent( "6 t Medical Supplies for Meridian Health", ); expect(p).toHaveTextContent("Arrives in 1m 30s"); }); it("shows a ship in the shop and a resting pilot", async () => { const shopped = mine({ inShopUntil: NOW + 120_000 }); renderApp( fakeApi({ getPlayerState: () => owner({ ships: [shopped], pilots: [ { ...avatar, assignedShipId: "Iron Mule", resting: true, restEndsAt: NOW + 300_000 }, ], }), }), ); const p = await panel(); expect(p).toHaveTextContent("In the shop โ€” ready in 2m 00s"); expect(within(p).getByLabelText("Your pilot")).toHaveTextContent("Resting ยท wakes in 5m 00s"); }); it("says so when you aren't flying a ship", async () => { renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) })); const p = await panel(); expect(p).toHaveTextContent("You're not flying a ship right now."); expect(within(p).getByRole("link", { name: "Assign yourself on Pilots" })).toHaveAttribute( "href", "/pilots", ); }); it("shows the company loaner during the tutorial", async () => { renderApp(fakeApi({ getPlayerState: () => samplePlayerState() })); const p = await panel(); expect(p).toHaveTextContent("Company loaner"); expect(p).toHaveTextContent("Supplied by Halvorsen"); expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth"); }); it("has an Alerts panel, empty for now", async () => { renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) })); const alerts = await screen.findByRole("region", { name: "Alerts" }); expect(alerts).toHaveTextContent("No alerts."); }); });