import type { Pilot, PlayerState, ResolutionSummary } 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 { sleepLabel } from "../src/components/FleetSleep"; import { independentStatus } from "../src/components/IndependentPilots"; import { pilotExtras } from "./fixtures"; import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render"; const NOW = Date.parse("2100-01-03T12:00:00Z"); const SLEPT = Date.parse("2100-01-02T09:30:00Z"); const rosa: Pilot = { ...samplePlayerState().pilots[0]!, id: "p1", name: "Rosa", isAvatar: false, assignedShipId: "s1", locationId: "earth", ...pilotExtras, mode: "independent", }; const summary: ResolutionSummary = { dispatchId: "d1", shipId: "s1", pilotId: "p1", originId: "earth", destinationId: "moon", tutorial: false, independent: true, deadhead: false, fuelCost: 100, topUp: null, goodId: "consumer-goods", massTonnes: 6, shipper: "Pacific Orbital Logistics", credits: 600, loanRepayment: 0, pilotPay: 200, pilotXp: 10, discoveredLocationId: null, scriptedId: null, event: null, restEndsAt: null, resolvedAt: SLEPT - 60_000, }; /** An owner with Rosa flying Independent; asleep since SLEPT, or awake. */ function owner(asleep: boolean, over: Partial = {}, afterHours = 24): PlayerState { return samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], pilots: [samplePlayerState().pilots[0]!, rosa], ships: [ { id: "s1", modelId: "courier", name: "Wren", status: "idle", locationId: "earth", dispatchId: null, lastArrivalAt: 0, ...shipExtras("courier"), pilotId: "p1", }, ], dispatchOffice: { owned: true, spendingFloor: 500 }, fleetSleep: { afterHours, sleepsAt: asleep ? SLEPT : NOW + 3_600_000, asleep }, ...over, }); } /** A fake API whose state wakes up on "Back to work". */ function sleepyApi(over: Partial = {}) { let state = owner(true, over); const api = fakeApi({ getPlayerState: () => state, backToWork: () => { state = owner(false, over); return state; }, acknowledgeSummaries: () => ({ acknowledged: 0 }), }); return api; } describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("shows the Bridge status while asleep, and Back to work wakes the fleet", async () => { const api = sleepyApi(); renderApp(api, "/"); const panel = await screen.findByLabelText("Fleet asleep"); expect(panel).toHaveTextContent("Fleet asleep — waiting for your orders"); await userEvent.click(within(panel).getByRole("button", { name: "Back to work" })); expect(api.call).toHaveBeenCalledWith("backToWork", {}); await waitFor(() => expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument()); }); it("shows nothing on the Bridge while awake", async () => { renderApp(fakeApi({ getPlayerState: () => owner(false) }), "/"); expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument(); }); it("shows nothing on the Bridge when no pilot is Independent: nobody is waiting", async () => { const manual = owner(true, { pilots: [samplePlayerState().pilots[0]!, { ...rosa, mode: "manual" }], }); renderApp(fakeApi({ getPlayerState: () => manual }), "/"); expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument(); }); it("puts the sleep banner on the while-you-were-away screen, until Back to work", async () => { comeBack(); const api = sleepyApi({ summaries: [summary] }); renderApp(api, "/"); const sheet = await screen.findByRole("dialog", { name: /While you were away/ }); const banner = within(sheet).getByLabelText("Fleet asleep"); expect(banner).toHaveTextContent( /^Your fleet went quiet on .+, .+ after 24 h without orders\./, ); await userEvent.click(within(banner).getByRole("button", { name: "Back to work" })); await waitFor(() => expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument(), ); }); it("leaves the banner off the away screen when the fleet didn't go quiet", async () => { comeBack(); renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/"); const sheet = await screen.findByRole("dialog", { name: /While you were away/ }); expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument(); }); it("says Waiting for orders for a sleeping Independent pilot", () => { const asleep = owner(true); expect(independentStatus(asleep, rosa)).toBe("Waiting for orders"); expect(independentStatus(owner(false), rosa)).toBe("Waiting for new contracts at Earth"); }); it("saves the threshold in Settings → Company and shows the new one", async () => { let state = owner(false); const api = fakeApi({ getPlayerState: () => state, setSleepThreshold: ({ hours }) => { state = owner(false, {}, hours); return state; }, }); renderApp(api, "/settings"); const group = await screen.findByRole("group", { name: "Fleet goes quiet after" }); expect( within(group) .getAllByRole("radio") .map((r) => r.parentElement!.textContent), ).toEqual(["12 h", "24 h", "48 h", "3 days"]); expect(within(group).getByRole("radio", { name: "24 h" })).toBeChecked(); await userEvent.click(within(group).getByRole("radio", { name: "3 days" })); expect(api.call).toHaveBeenCalledWith("setSleepThreshold", { hours: 72 }); await waitFor(() => expect(within(group).getByRole("radio", { name: "3 days" })).toBeChecked()); }); it("words the thresholds in hours, then days", () => { expect([12, 24, 48, 72].map(sleepLabel)).toEqual(["12 h", "24 h", "48 h", "3 days"]); }); });