deadhead-space-freight-tycoon/apps/web/test/service.test.tsx

287 lines
11 KiB
TypeScript

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<Ship["booking"]>;
const ship = (over: Partial<Ship> = {}): 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> = {}): 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<PlayerState> = {}) =>
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<typeof fakeApi>) {
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<Extract<LogEntry, { kind: "service" }>> = {},
): 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(<PilotLogbook logbook={book} state={s} />);
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",
]);
});
});