import { content } from "@deadhead/content"; import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema"; import { emergencyFuel, fuelPriceAt, tripFuel } 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 { useClock } from "../src/clock/clock"; import { formatCredits } from "../src/format"; import { simShip } from "../src/game"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const [earth, moon] = [content.location("earth")!, content.location("moon")!]; const courier = content.shipModel("courier")!; const earthPrice = fuelPriceAt(earth, content.config); const legFu = tripFuel(courier, earth, moon, content.config); const ship = (fuelFu: number, refuelPolicy: Ship["refuelPolicy"] = "auto"): Ship => ({ id: "s1", name: "Dusty Mule", modelId: "courier", status: "idle", locationId: "earth", dispatchId: null, lastArrivalAt: 0, ...shipExtras("courier"), fuelFu, fuelAvgPrice: 15, refuelPolicy, pilotId: "avatar", }); const avatar: Pilot = { id: "avatar", name: "Captain", isAvatar: true, isVeteran: false, xp: 0, level: 0, skills: {}, skillPointsAvailable: 0, assignedShipId: "s1", status: "idle", locationId: "earth", dutyHours: 0, dutyLimit: 12, resting: false, restEndsAt: null, }; const moonJob: Contract = { id: "c-moon", originId: "earth", destinationId: "moon", cargoTypeId: "standard", goodId: "consumer-goods", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 0, massTonnes: 5, payout: 900, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: NOW, expiresAt: NOW + 3_600_000, firstContact: false, }; /** A one-ship company with `fuelFu` on board and `credits`, whose refuels really fill the tank. */ function company(fuelFu: number, credits = 10_000) { let state: PlayerState = samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance: 0 }, bays: [{ id: "b1", locationId: "earth" }], ships: [ship(fuelFu)], pilots: [avatar], }); const refuelShip = vi.fn((input: { shipId: string; amountFu?: number }) => { const s = state.ships[0]!; const bought = input.amountFu ?? s.stats.tankCapacityFu - s.fuelFu; const refuelled = { ...s, fuelFu: s.fuelFu + bought }; state = { ...state, company: { ...state.company, credits: state.company.credits - Math.round(bought * earthPrice), }, ships: [refuelled], }; return { ship: refuelled }; }); const api = fakeApi({ getPlayerState: () => state, getContractBoard: () => ({ contracts: [moonJob], periodEndsAt: NOW + 3_600_000 }), refuelShip, }); return { api, refuelShip, state: () => state }; } describe("fuel in the client (Step 15.7)", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("shows each ship's fuel gauge and refuel policy on the Fleet screen", async () => { renderApp(company(30).api, "/fleet"); const gauge = await screen.findByRole("progressbar", { name: "Dusty Mule fuel" }); // The gauge runs from empty to the tank's capacity, in fu. expect(gauge).toHaveAttribute("aria-valuenow", "30"); expect(gauge).toHaveAttribute("aria-valuemax", String(courier.tankCapacityFu)); expect(screen.getByLabelText("Dusty Mule fuel level")).toHaveTextContent("30 fu · 75%"); expect(screen.getByText("Auto top-up")).toBeInTheDocument(); }); it('offers "Refuel here first" for a pair short of fuel, fills the tank and enables it', async () => { const { api, refuelShip } = company(2); renderApp(api, "/contracts"); await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); const dialog = screen.getByRole("dialog"); const pair = within(dialog).getByText("Dusty Mule").closest("li")!; expect(within(pair).getByRole("radio")).toBeDisabled(); expect(pair).toHaveTextContent("Not enough fuel"); expect(within(pair).getByLabelText("Fuel")).toHaveTextContent( `Fuel needed ${Math.round(legFu)} fu · on board 2 fu`, ); await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" })); // A full tank: the credits cover it. expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1" }); // The sheet stays open and the pair is now flyable and picked. await waitFor(() => expect(within(dialog).getByRole("radio")).toBeEnabled()); expect(within(dialog).getByRole("radio")).toBeChecked(); expect(screen.getByRole("dialog")).toBe(dialog); }); it("buys just the leg's fuel when the credits can't fill the tank", async () => { const { api, refuelShip } = company(2, Math.round(10 * earthPrice)); renderApp(api, "/contracts"); await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!; await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" })); expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1", amountFu: legFu - 2 }); }); it("prices the fuel used at the tank's average price, as the sim does", async () => { renderApp(company(40).api, "/contracts"); await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!; expect(pair).toHaveTextContent( `Fuel used (at your average price)${formatCredits(Math.round(legFu * 15))}`, ); }); it("highlights the cheapest fuel on the Locations screen", async () => { const all = samplePlayerState({ tutorial: null, locations: content.locations.map((l) => ({ id: l.id, discovered: true })), }); renderApp(fakeApi({ getPlayerState: () => all }), "/locations"); const jupiter = await screen.findByLabelText("Fuel at Jupiter Cloud Platform"); expect(jupiter).toHaveTextContent("6 cr/fu · cheapest"); expect(screen.getByLabelText("Fuel at Earth")).toHaveTextContent("16 cr/fu"); expect(screen.getByLabelText("Fuel at Earth")).not.toHaveTextContent("cheapest"); }); it("shows emergency fuel only when the sim offers it", async () => { // Stuck at the Moon with 1 fu and no credits: the only way out is Earth. const stuck = samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits: 0, loanBalance: 0 }, ships: [{ ...ship(1), locationId: "moon" }], pilots: [avatar], }); const offer = emergencyFuel( { ship: simShip(stuck.ships[0]!), credits: 0, discoveredIds: new Set(["earth", "moon"]), }, content, )!; const { unmount } = renderApp(fakeApi({ getPlayerState: () => stuck }), "/fleet"); await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" })); expect( screen.getByRole("button", { name: /Emergency fuel \(added to loan\)/ }), ).toHaveTextContent(formatCredits(offer.cost)); unmount(); const fine = samplePlayerState({ tutorial: null, ships: [ship(40)], pilots: [avatar] }); renderApp(fakeApi({ getPlayerState: () => fine }), "/fleet"); await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" })); expect(screen.queryByRole("button", { name: /Emergency fuel/ })).toBeNull(); }); });