import { content } from "@deadhead/content"; import type { Contract, LogEntry, Pilot, PlayerState, Ship } from "@deadhead/schema"; import { repairCost } from "@deadhead/sim"; import { buildAwaySummary } from "@deadhead/sim"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { highlightText } from "../src/components/AwaySummary"; import { independentStatus } from "../src/components/IndependentPilots"; import { PilotLogbook, pilotLogbook } from "../src/components/PilotLogbook"; import { serviceStatus, serviceTag } from "../src/components/Service"; import { formatCredits } from "../src/format"; import { pairOptions } from "../src/screens/DispatchSheet"; import { pilotExtras } from "./fixtures"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; type Booking = NonNullable; const ship = (over: Partial = {}): Ship => ({ id: "s1", name: "Dented Wren", modelId: "courier", status: "idle", locationId: "moon", dispatchId: null, pilotId: "p1", lastArrivalAt: 0, ...shipExtras("courier"), hullConditionPct: 60, ...over, }); const booking = (over: Partial = {}): Booking => ({ repair: true, upgradeId: null, upgradeLevel: null, hqId: "earth", by: "player", estimate: 4_000, status: "heading", ...over, }); const rosa: Pilot = { ...samplePlayerState().pilots[0]!, id: "p1", name: "Rosa", isAvatar: false, assignedShipId: "s1", locationId: null, ...pilotExtras, mode: "independent", }; const state = (s: Ship, over: Partial = {}) => samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 }, bays: [{ id: "b1", locationId: "earth" }], ships: [s], pilots: [samplePlayerState().pilots[0]!, rosa], ...over, }); async function openService(api: ReturnType) { renderApp(api, "/fleet"); const card = (await screen.findByText("Dented Wren")).closest("li")!; await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Dented Wren" })); return within(screen.getByRole("dialog", { name: "Dented Wren" })).getByLabelText("Service"); } describe("the Service section (Step 27.5, decision 0.5.153)", () => { it("books service with the estimate and the nearest reachable HQ by default", async () => { const api = fakeApi({ getPlayerState: () => state(ship()), bookService: () => ({ ship: ship({ booking: booking() }) }), }); const section = await openService(api); await userEvent.click(within(section).getByRole("button", { name: "Book service" })); const form = within(section).getByRole("group", { name: "Book service" }); expect(within(form).getByLabelText("HQ")).toHaveValue("earth"); expect(within(form).getByRole("option", { name: "Earth (nearest)" })).toBeInTheDocument(); const cost = repairCost(content.shipModel("courier")!, 60, content.config); expect(within(form).getByLabelText("Estimate")).toHaveTextContent(formatCredits(cost)); await userEvent.selectOptions(within(form).getByLabelText("Upgrade"), "cargo-bay"); await userEvent.click(within(form).getByRole("button", { name: "Book service" })); expect(api.call).toHaveBeenCalledWith("bookService", { shipId: "s1", repair: true, upgradeId: "cargo-bay", hqId: "earth", }); }); it("shows a booking's items, estimate and status, and changes or cancels it", async () => { const booked = ship({ booking: booking({ upgradeId: "cargo-bay", upgradeLevel: 1, estimate: 12_345 }), }); const api = fakeApi({ getPlayerState: () => state(booked), cancelBooking: () => ({ ship: ship() }), bookService: () => ({ ship: booked }), }); const section = await openService(api); expect(section).toHaveTextContent( `Repair + Cargo Bay Expansion 1 at Earth · ${formatCredits(12_345)}`, ); expect(within(section).getByLabelText("Service status")).toHaveTextContent( "Heading to Earth for service", ); await userEvent.click(within(section).getByRole("button", { name: "Cancel booking" })); expect(api.call).toHaveBeenCalledWith("cancelBooking", { shipId: "s1" }); await userEvent.click(within(section).getByRole("button", { name: "Change" })); const form = within(section).getByRole("group", { name: "Book service" }); expect(within(form).getByLabelText("Upgrade")).toHaveValue("cargo-bay"); await userEvent.selectOptions(within(form).getByLabelText("Upgrade"), ""); await userEvent.click(within(form).getByRole("button", { name: "Save booking" })); expect(api.call).toHaveBeenCalledWith("bookService", { shipId: "s1", repair: true, upgradeId: null, hqId: "earth", }); }); it("can't change work that has started", async () => { const api = fakeApi({ getPlayerState: () => state(ship({ booking: booking({ status: "in-shop" }) })), }); const section = await openService(api); expect(within(section).queryByRole("button", { name: "Change" })).not.toBeInTheDocument(); expect( within(section).queryByRole("button", { name: "Cancel booking" }), ).not.toBeInTheDocument(); }); it("saves the auto-repair threshold in steps of 5, and turns it off", async () => { const api = fakeApi({ getPlayerState: () => state(ship()), setAutoRepair: () => ({ ship: ship() }), }); const section = await openService(api); const slider = within(section).getByLabelText("Auto-repair threshold"); expect(slider).toHaveAttribute("step", "5"); expect(slider).toHaveAttribute("min", "10"); expect(slider).toHaveAttribute("max", "90"); fireEvent.change(slider, { target: { value: "55" } }); await waitFor(() => expect(api.call).toHaveBeenCalledWith("setAutoRepair", { shipId: "s1", on: true, thresholdPct: 55, }), ); await userEvent.click(within(section).getByRole("checkbox", { name: /Auto-repair below 40%/ })); expect(api.call).toHaveBeenCalledWith("setAutoRepair", { shipId: "s1", on: false, thresholdPct: 40, }); }); }); describe("service tags and status lines (Step 27.5, CR-027 §4)", () => { it("tags the Fleet card with the booking, or Repair recommended", async () => { renderApp( fakeApi({ getPlayerState: () => state( ship({ booking: booking({ upgradeId: "engines", upgradeLevel: 2, repair: false }), }), ), }), "/fleet", ); expect(await screen.findByLabelText("Dented Wren service")).toHaveTextContent( "Service booked: Engine Tuning 2 at Earth", ); }); it("words each state", () => { const at = (status: Booking["status"]) => ship({ booking: booking({ status }) }); expect(serviceStatus(at("heading"), rosa)).toBe("Heading to Earth for service"); expect(serviceStatus(at("in-shop"), { ...rosa, restingInShopUntil: 1 })).toBe( "In the shop at Earth (resting)", ); expect(serviceStatus(at("in-shop"), rosa)).toBe("In the shop at Earth"); expect(serviceStatus(at("waiting-funds"), rosa)).toBe("Waiting for funds: Repair at Earth"); expect(serviceStatus(at("cant-reach"), rosa)).toBe("Can't reach Earth for service"); expect(serviceStatus(ship(), rosa)).toBeNull(); expect(serviceTag(ship({ repairRecommended: true }))).toBe("Repair recommended"); expect(serviceTag(ship())).toBeNull(); }); it("puts the service first in an Independent pilot's status line", () => { const heading = ship({ booking: booking() }); expect(independentStatus(state(heading), rosa)).toBe("Heading to Earth for service"); // Out of reach: the pilot works as usual, and the line says so. const far = ship({ booking: booking({ status: "cant-reach" }) }); expect(independentStatus(state(far), { ...rosa, locationId: "moon" })).not.toMatch(/service/); }); it("tags the ship in the dispatch sheet", () => { const job: Contract = { id: "c", originId: "moon", destinationId: "earth", cargoTypeId: "standard", goodId: "consumer-goods", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 0, massTonnes: 5, payout: 1_000, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: 0, expiresAt: 600_000, firstContact: false, }; const [pair] = pairOptions(state(ship({ booking: booking({ status: "booked" }) })), job, 0); expect(pair!.service).toBe("Service booked: Repair at Earth"); }); }); describe("service in the Logbook and the away summary (Step 27.6)", () => { const entry = ( phase: "booked" | "started" | "finished" | "waiting-funds", over: Partial> = {}, ): LogEntry => ({ id: phase, at: 1_000, pilotId: "p1", shipId: "s1", seen: false, kind: "service", phase, hqId: "earth", repair: true, upgradeId: null, by: "auto", cost: null, ...over, }); it("lists automatic bookings, service started and finished, and waiting for funds", () => { const s = state(ship()); const book = pilotLogbook(s, [ entry("booked"), entry("waiting-funds"), entry("started", { at: 2_000, cost: 3_200 }), entry("finished", { at: 3_000, upgradeId: "engines" }), ]); render(); expect( screen.getByText("Dented Wren: Repair booked automatically at Earth"), ).toBeInTheDocument(); expect( screen.getByText("Dented Wren: waiting for funds for Repair at Earth"), ).toBeInTheDocument(); expect( screen.getByText(`Dented Wren: service started at Earth (Repair), ${formatCredits(3_200)}`), ).toBeInTheDocument(); expect( screen.getByText("Dented Wren: service finished at Earth (Repair + Engine Tuning)"), ).toBeInTheDocument(); }); it("highlights service waiting for funds or out of reach", () => { const s = state(ship({ booking: booking({ status: "waiting-funds" }) })); const summary = buildAwaySummary({ from: 0, to: 1_000, entries: [], pilots: [{ id: "p1", name: "Rosa", now: "At Moon" }], problems: [ { kind: "service-waiting-funds", shipId: "s1", hqId: "earth" }, { kind: "service-cant-reach", shipId: "s1", hqId: "mars" }, ], }); expect(summary.highlights.map((h) => highlightText(h, s))).toEqual([ "Dented Wren is waiting for funds: Repair at Earth", "Dented Wren can't reach Mars for its booked service", ]); }); });