deadhead-space-freight-tycoon/apps/web/test/away-summary.test.tsx

257 lines
8.4 KiB
TypeScript

import type { LogbookResponse, LogEntry, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
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 { formatAbsence } from "../src/components/AwaySummary";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T12:00:00Z");
const FROM = NOW - (9 * 60 + 40) * 60_000;
const ship = (id: string, pilotId: string): Ship => ({
id,
modelId: "courier",
name: id === "s0" ? "Dusty Mule" : `Ship ${id}`,
status: "idle",
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId,
});
const pilot = (i: number): Pilot => ({
...samplePlayerState().pilots[0]!,
id: `p${i}`,
name: i === 0 ? "Rosa" : `Pilot ${i}`,
isAvatar: false,
assignedShipId: `s${i}`,
locationId: "moon",
...pilotExtras,
mode: "independent",
});
/** A summary that keeps the pop-up open (the trigger is unchanged: unseen deliveries). */
const pending: ResolutionSummary = {
dispatchId: "d0",
shipId: "s0",
pilotId: "p0",
originId: "earth",
destinationId: "moon",
tutorial: false,
independent: true,
deadhead: false,
fuelCost: 100,
topUp: null,
goodId: "consumer-goods",
massTonnes: 6,
shipper: "Pacific Orbital Logistics",
credits: 700,
loanRepayment: 0,
pilotPay: 200,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
event: null,
restEndsAt: null,
resolvedAt: NOW - 60_000,
};
let n = 0;
const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}): LogEntry =>
({
id: `f${n++}`,
at: FROM + 60_000 + n,
pilotId,
shipId: `s${pilotId.slice(1)}`,
seen: false,
kind: "flight",
dispatchId: `d${n}`,
deadhead: false,
tutorial: false,
independent: true,
originId: "earth",
destinationId: "moon",
goodId: "helium-3",
massTonnes: 6,
shipper: "Pacific Orbital Logistics",
payout,
pilotPay: 0,
fuelCost: 0,
loanRepayment: 0,
xp: 10,
scriptedId: null,
...over,
}) as LogEntry;
function render(pilots: number, entries: LogEntry[], over: Partial<LogbookResponse> = {}) {
const absence = over.absence ?? { from: FROM, to: NOW };
comeBack({ from: absence.from, to: absence.to + 1 });
let seen = false;
const all = Array.from({ length: pilots }, (_, i) => pilot(i));
const api = fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
pilots: [samplePlayerState().pilots[0]!, ...all],
ships: all.map((p, i) => ship(`s${i}`, p.id)),
summaries: seen ? [] : [pending],
}),
getLogbook: () => ({
entries,
absence: { from: FROM, to: NOW },
lastAbsence: null,
problems: [],
...over,
}),
acknowledgeSummaries: () => {
seen = true;
return { acknowledged: 1 };
},
});
renderApp(api);
return api;
}
const dialog = () => screen.findByRole("dialog", { name: /While you were away/ });
describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("titles the absence with its length", async () => {
render(1, [flight("p0", 410)]);
const sheet = await dialog();
await within(sheet).findByRole("list", { name: "What happened" });
expect(sheet).toHaveAccessibleName("While you were away · 9 h 40 m");
expect([formatAbsence(12 * 60_000), formatAbsence(50 * 3_600_000)]).toEqual([
"12 m",
"2 d 2 h",
]);
});
it("lists a short absence's events directly: no totals or pilot lines", async () => {
render(2, [
flight("p0", 410, { goodId: "consumer-goods" }),
flight("p1", 0, { deadhead: true, goodId: null, fuelCost: 230 }),
]);
const list = await within(await dialog()).findByRole("list", { name: "What happened" });
expect(
within(list)
.getAllByRole("listitem")
.map((li) => li.textContent),
).toEqual([
"Dusty Mule delivered Consumer Goods to Moon, +410 cr",
"Ship s1 flew empty to Moon, 230 cr fuel",
]);
expect(screen.queryByLabelText("Company totals")).not.toBeInTheDocument();
expect(screen.queryByRole("list", { name: "Pilots" })).not.toBeInTheDocument();
});
it("with 50 active pilots shows totals, 3 highlights, 5 pilot lines and the rest summed", async () => {
const entries = Array.from({ length: 50 }, (_, i) => [
flight(`p${i}`, (i + 1) * 100, { fuelCost: 10 }),
flight(`p${i}`, (i + 1) * 100, { fuelCost: 10 }),
]).flat();
entries.push(
{
id: "x1",
at: FROM + 5,
pilotId: "p3",
shipId: "s3",
seen: false,
kind: "discovery",
locationId: "mars",
},
{
id: "x2",
at: FROM + 6,
pilotId: "p0",
shipId: "s0",
seen: false,
kind: "level-up",
level: 4,
skillPoints: 1,
},
);
render(50, entries, {
problems: [{ kind: "low-fuel", shipId: "s2", locationId: "moon" }],
});
const sheet = await dialog();
const totals = await within(sheet).findByLabelText("Company totals");
const net = entries.reduce(
(sum, e) => sum + (e.kind === "flight" ? e.payout - e.fuelCost : 0),
0,
);
expect(totals).toHaveTextContent(`Net earned+${net.toLocaleString("en-US")} cr`);
expect(totals).toHaveTextContent("Deliveries100");
expect(totals).toHaveTextContent("Discoveries1");
expect(totals).not.toHaveTextContent("Loan repaid");
const highlights = within(sheet).getByRole("list", { name: "Highlights" });
expect(
within(highlights)
.getAllByRole("listitem")
.map((li) => li.textContent),
).toEqual([
"New market: Mars",
"Rosa reached level 4 · 1 skill point",
"Biggest job: Helium-3 Canisters to Moon, 5,000 cr, Pilot 49",
"+1 more in the Logbook",
]);
const lines = within(sheet).getByRole("list", { name: "Pilots" });
const items = within(lines).getAllByRole("listitem");
// 5 pilots, the other 45, and the Captain, who flew nothing.
expect(items).toHaveLength(7);
expect(items[6]).toHaveTextContent("1 pilot had nothing to do");
expect(items[0]).toHaveTextContent("Pilot 49 · 2 jobs · +9,980 cr · At Moon");
const rest = Array.from({ length: 45 }, (_, i) => 2 * ((i + 1) * 100 - 10)).reduce(
(a, b) => a + b,
);
expect(items[5]).toHaveTextContent(`+45 more pilots: +${rest.toLocaleString("en-US")} cr`);
// Rosa leveled up but is 50th by net: her badge is in the collapsed line, not shown.
expect(within(lines).queryByText("Level up")).not.toBeInTheDocument();
});
it("adds +N more lines only when needed, and sums idle pilots", async () => {
render(4, [flight("p0", 100), flight("p0", 100), flight("p1", 50), flight("p1", 50)]);
const sheet = await dialog();
const lines = await within(sheet).findByRole("list", { name: "Pilots" });
expect(
within(lines)
.getAllByRole("listitem")
.map((li) => li.textContent),
).toEqual([
"Rosa · 2 jobs · +200 cr · At Moon",
"Pilot 1 · 2 jobs · +100 cr · At Moon",
// The two without flights, and the Captain.
"3 pilots had nothing to do",
]);
expect(within(sheet).queryByText(/more in the Logbook/)).not.toBeInTheDocument();
});
it("closes on Done, acknowledging what it showed", async () => {
const api = render(1, [flight("p0", 410)]);
const sheet = await dialog();
await within(sheet).findByRole("list", { name: "What happened" });
await userEvent.click(within(sheet).getByRole("button", { name: "Done" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d0"] }),
);
});
it("does not show the dialog when there is nothing to report", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});