import type { Pilot, PlayerState, RadioCall, Ship } from "@deadhead/schema"; import { act, 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 { useRadioLater } from "../src/components/RadioCalls"; import { tabAttention } from "../src/layout/attention"; import { pilotExtras } from "./fixtures"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T12:00:00Z"); const rosa: Pilot = { ...samplePlayerState().pilots[0]!, id: "p1", name: "Rosa", isAvatar: false, assignedShipId: "s1", status: "in-flight", locationId: null, ...pilotExtras, }; const ship: Ship = { id: "s1", modelId: "clipper", name: "Heron", status: "in-flight", locationId: null, dispatchId: "d1", lastArrivalAt: 0, ...shipExtras("clipper"), pilotId: "p1", }; const call = (over: Partial = {}): RadioCall => ({ dispatchId: "d1", shipId: "s1", pilotId: "p1", eventId: "debris-field", fireAt: NOW - 60_000, windowEndsAt: NOW + 12 * 60_000 + 34_000, options: [ { id: "go-around", available: true }, { id: "push-through", available: true }, ], defaultOptionId: "go-around", independent: false, ...over, }); const owner = (calls: RadioCall[]): PlayerState => samplePlayerState({ tutorial: null, pilots: [samplePlayerState().pilots[0]!, rosa], ships: [ship], radioCalls: calls, }); /** The app with `calls` waiting; answering removes the call answered. */ function render(calls: RadioCall[], path = "/fleet") { let waiting = calls; const api = fakeApi({ getPlayerState: () => owner(waiting), answerRadioCall: ({ dispatchId }) => { waiting = waiting.filter((c) => c.dispatchId !== dispatchId); return { radioCalls: waiting }; }, }); renderApp(api, path); return { api, expire: () => (waiting = []) }; } describe("radio calls (Step 26.6, decision 0.5.144)", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); useRadioLater.setState({ later: new Set() }); }); afterEach(() => vi.useRealTimers()); it("shows a waiting call's story, options with what they might do, and the time left", async () => { render([call()]); const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" }); expect(sheet).toHaveTextContent("Rosa reports a field of rock and wreckage across the route."); expect(within(sheet).getByLabelText("Time to answer")).toHaveTextContent( "Answer within 12:34 โ€” then Rosa will go around.", ); const options = within(sheet).getByRole("list", { name: "Options" }); expect( within(options) .getAllByRole("button") .map((b) => b.textContent), ).toEqual([ "Go around (if you don't answer)+3 h", "Push through35% chance: hull โˆ’10โ€“20% ยท 10% chance: lose 10% of the pay", ]); }); it("answers through the engine, and the call leaves the queue", async () => { const { api } = render([call()]); const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" }); await userEvent.click(within(sheet).getByRole("button", { name: /^Push through/ })); expect(api.call).toHaveBeenCalledWith("answerRadioCall", { dispatchId: "d1", optionId: "push-through", }); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); it("goes away when the window closes unanswered", async () => { const { expire } = render([call()]); await screen.findByRole("dialog", { name: "Debris field ahead" }); expire(); act(() => useClock.setState({ now: NOW + 13 * 60_000 })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); it("queues several calls as Radio calls (2)", async () => { render([ call(), call({ dispatchId: "d2", eventId: "storm-approach", windowEndsAt: NOW + 20 * 60_000 }), ]); const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" }); expect(sheet).toHaveTextContent("Radio calls (2)"); }); it("badges the Bridge and lists the calls there, reopening one put off", async () => { expect(tabAttention(owner([call(), call({ dispatchId: "d2" })]))["/"]).toBe(2); render([call()], "/"); const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" }); await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); const panel = screen.getByLabelText("Radio calls"); expect(panel).toHaveTextContent("Debris field ahead"); await userEvent.click(within(panel).getByRole("button", { name: "Answer" })); expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument(); }); it("shows an option the trip can't take as not possible", async () => { render([ call({ eventId: "derelict-pod", options: [ { id: "salvage", available: false }, { id: "log-it", available: true }, ], defaultOptionId: "log-it", }), ]); const sheet = await screen.findByRole("dialog", { name: "Derelict cargo pod" }); const salvage = within(sheet).getByRole("button", { name: /^Salvage it/ }); expect(salvage).toBeDisabled(); expect(salvage).toHaveTextContent("Not possible on this trip"); }); });