252 lines
8.2 KiB
TypeScript
252 lines
8.2 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 } 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)}`,
|
|
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> = {}) {
|
|
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",
|
|
kind: "discovery",
|
|
locationId: "mars",
|
|
},
|
|
{
|
|
id: "x2",
|
|
at: FROM + 6,
|
|
pilotId: "p0",
|
|
shipId: "s0",
|
|
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();
|
|
});
|
|
});
|