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 => ({ 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 = {}) { 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(); }); });