import type { LogEntry, PlayerState, Ship } from "@deadhead/schema"; import { flightNet } from "@deadhead/sim"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, useLocation } from "react-router"; import { entryToasts } from "../src/components/ToastFeed"; import { Toasts } from "../src/components/Toasts"; import { usePresence } from "../src/platform/presence"; import { usePreferences } from "../src/state/preferences"; import { batchText, useToasts, type NewToast } from "../src/state/toasts"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); function Where() { return

{useLocation().pathname}

; } function renderToasts() { return render( , ); } const toast = (text: string, over: Partial = {}): NewToast => ({ text, tone: "info", to: "/fleet", ...over, }); function setWidth(width: number) { Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); window.dispatchEvent(new Event("resize")); } const ship: Ship = { id: "s1", name: "Wren", modelId: "courier", status: "idle", locationId: "earth", dispatchId: null, pilotId: "p1", lastArrivalAt: 0, ...shipExtras("courier"), }; const state = (): PlayerState => samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits: 50_000, loanBalance: 0 }, ships: [ship], pilots: [{ ...samplePlayerState().pilots[0]!, id: "p1", name: "Rosa", isAvatar: false }], }); function delivery( id: string, at: number, over: Partial> = {}, ) { return { id, at, pilotId: "p1", shipId: "s1", seen: false, kind: "flight", dispatchId: id, deadhead: false, tutorial: false, independent: true, originId: "earth", destinationId: "moon", goodId: "consumer-goods", massTonnes: 5, shipper: "Pacific Orbital Logistics", payout: 1_500, pilotPay: 200, fuelCost: 100, loanRepayment: 0, xp: 5, scriptedId: null, ...over, } satisfies LogEntry; } const discovery = (id: string, at: number): LogEntry => ({ id, at, pilotId: "p1", shipId: "s1", seen: false, kind: "discovery", locationId: "mars", }); afterEach(() => { vi.useRealTimers(); setWidth(1024); }); describe("toasts on screen (Step 28.3, decision 0.5.161)", () => { it("sit top-right on a desktop and top-center below the safe area on a phone", () => { setWidth(1280); act(() => useToasts.getState().push([toast("Wren delivered Mail to the Moon")])); renderToasts(); const region = screen.getByRole("region", { name: "Notifications" }); expect(region).toHaveAttribute("data-placement", "top-right"); expect(region).toHaveAttribute("aria-live", "polite"); act(() => setWidth(375)); expect(region).toHaveAttribute("data-placement", "top-center"); expect(region.className).toContain("safe-area-inset-top"); }); it("closes after 5 seconds, but not while hovered", () => { vi.useFakeTimers(); act(() => useToasts.getState().push([toast("First"), toast("Hovered")])); renderToasts(); const hovered = screen.getByText("Hovered").closest("li")!; fireEvent.mouseEnter(hovered); act(() => vi.advanceTimersByTime(4_999)); expect(screen.getByText("First")).toBeInTheDocument(); act(() => vi.advanceTimersByTime(1)); expect(screen.queryByText("First")).not.toBeInTheDocument(); act(() => vi.advanceTimersByTime(20_000)); expect(screen.getByText("Hovered")).toBeInTheDocument(); fireEvent.mouseLeave(hovered); act(() => vi.advanceTimersByTime(5_000)); expect(screen.queryByText("Hovered")).not.toBeInTheDocument(); }); it("closes early with ✕ or a swipe", () => { act(() => useToasts.getState().push([toast("Swiped"), toast("Closed")])); renderToasts(); fireEvent.click( within(screen.getByText("Closed").closest("li")!).getByRole("button", { name: "Dismiss" }), ); expect(screen.queryByText("Closed")).not.toBeInTheDocument(); const swiped = screen.getByText("Swiped").closest("li")!; fireEvent.pointerDown(swiped, { clientX: 200 }); fireEvent.pointerUp(swiped, { clientX: 300 }); expect(screen.queryByText("Swiped")).not.toBeInTheDocument(); }); it("shows at most 3, the newest on top, and the rest as +N more, which opens the Logbook", () => { act(() => useToasts.getState().push(["One", "Two", "Three", "Four"].map((t) => toast(t)))); renderToasts(); expect(screen.getAllByRole("listitem").map((li) => li.textContent)).toEqual([ "Four", "Three", "Two", ]); fireEvent.click(screen.getByRole("button", { name: "+1 more" })); expect(screen.getByTestId("where")).toHaveTextContent("/logbook"); expect(screen.queryByRole("button", { name: /more/ })).not.toBeInTheDocument(); }); it("opens the related screen when tapped", () => { act(() => useToasts.getState().push(entryToasts([discovery("x1", NOW)], state()))); renderToasts(); fireEvent.click(screen.getByRole("button", { name: /opened a new market: Mars/ })); expect(screen.getByTestId("where")).toHaveTextContent("/locations"); expect(screen.queryByText(/opened a new market/)).not.toBeInTheDocument(); }); it("appears without sliding with reduced motion", () => { usePreferences.setState({ reducedMotion: true }); act(() => useToasts.getState().push([toast("Still")])); renderToasts(); expect(screen.getByRole("region", { name: "Notifications" })).toHaveAttribute( "data-motion", "none", ); expect(screen.getByText("Still").closest("li")!.className).not.toMatch(/animate-/); usePreferences.setState({ reducedMotion: null }); }); }); describe("what gets a toast (Step 28.3)", () => { it("combines Independent deliveries within 10 seconds, and gives a discovery its own", () => { const entries = [delivery("d1", NOW), delivery("d2", NOW + 1_000), discovery("x1", NOW + 2)]; act(() => useToasts.getState().push(entryToasts(entries, state()), NOW)); // A third delivery 8 seconds later joins the same toast. act(() => useToasts.getState().push(entryToasts([delivery("d3", NOW + 8_000)], state()), NOW + 8_000), ); const net = 3 * flightNet(delivery("d", 0)); expect(useToasts.getState().toasts.map((t) => t.text)).toEqual([ "Rosa opened a new market: Mars", batchText(3, net), ]); // Past the window, a new toast starts. act(() => useToasts.getState().push(entryToasts([delivery("d4", NOW)], state()), NOW + 20_000)); expect(useToasts.getState().toasts).toHaveLength(3); }); it("gives a delivery the player sent its own toast, and none to a deadhead", () => { const toasts = entryToasts( [ delivery("d1", NOW, { independent: false }), delivery("d2", NOW, { independent: false }), delivery("d3", NOW, { deadhead: true, goodId: null, payout: 0 }), ], state(), ); expect(toasts).toHaveLength(2); expect(toasts.every((t) => t.batch === undefined)).toBe(true); }); it("toasts the unseen entries from while the player is here and marks them seen", async () => { usePresence.setState({ here: true, hereSince: NOW - 60_000 }); const before = { ...discovery("old", NOW - 120_000) }; const api = fakeApi({ getPlayerState: () => state(), getLogbook: () => ({ entries: [ before, delivery("d1", NOW - 10_000, { independent: false }), discovery("x1", NOW), ], absence: { from: NOW - 60_000, to: NOW }, lastAbsence: null, problems: [], }), }); renderApp(api, "/fleet"); const region = await screen.findByRole("region", { name: "Notifications" }); await waitFor(() => expect(within(region).getAllByRole("listitem")).toHaveLength(2)); expect(region).toHaveTextContent("Wren delivered"); expect(region).toHaveTextContent("opened a new market: Mars"); await waitFor(() => expect(api.call).toHaveBeenCalledWith("markLogbookSeen", { entryIds: ["d1", "x1"] }), ); }); });