deadhead-space-freight-tycoon/apps/web/test/toasts.test.tsx

238 lines
8.2 KiB
TypeScript

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 <p data-testid="where">{useLocation().pathname}</p>;
}
function renderToasts() {
return render(
<MemoryRouter initialEntries={["/fleet"]}>
<Toasts />
<Where />
</MemoryRouter>,
);
}
const toast = (text: string, over: Partial<NewToast> = {}): 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<Extract<LogEntry, { kind: "flight" }>> = {},
) {
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"] }),
);
});
});