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 } 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, 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, expectedPayout: 0, seed: 1, status: "in-flight", resolvedAt: null, }); describe("HQ screen", () => { 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 counts down to the next arrival", 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: "HQ" }); const statValue = (label: string) => screen.getByText(label).nextElementSibling; expect(statValue("Ships idle")).toHaveTextContent("1"); expect(statValue("Ships in flight")).toHaveTextContent("2"); const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!; expect(within(next).getByText("1m 05s")).toBeInTheDocument(); act(() => vi.advanceTimersByTime(10_000)); expect(within(next).getByText("55s")).toBeInTheDocument(); }); 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.config.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: "HQ" }); expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument(); }); it("shows the lender's cut in a delivery summary", async () => { const state: PlayerState = { ...owner(2000, 4000), summaries: [ { dispatchId: "d1", shipId: "s1", pilotId: "avatar", originId: "earth", destinationId: "moon", tutorial: false, credits: 538, deadhead: false, fuelCost: 0, loanRepayment: 230, pilotXp: 10, discoveredLocationId: null, scriptedId: null, restEndsAt: null, resolvedAt: 1, }, ], }; renderApp( fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }), ); const dialog = await screen.findByRole("dialog", { name: "While you were away" }); expect(dialog).toHaveTextContent("230 cr went to the lender"); }); });