Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions packages/web/src/components/GameDetailLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { act, render } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from "react-router";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { describe, it, expect, vi, beforeAll } from "vitest";
import { GameDetailLayout } from "./GameDetailLayout";

vi.mock("@/api/generated/endpoints", async importOriginal => {
const actual =
await importOriginal<typeof import("@/api/generated/endpoints")>();
return {
...actual,
useGameRetrieve: (gameId: string) =>
useQuery({
queryKey: actual.getGameRetrieveQueryKey(gameId),
queryFn: () => new Promise(() => {}),
enabled: false,
}),
};
});

vi.mock("@/components/GameMap", () => ({ GameMap: () => null }));
vi.mock("@/components/OfflineBanner", () => ({ OfflineBanner: () => null }));

beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});

const pollGame = async (
queryClient: QueryClient,
gameId: string,
game: { currentPhaseId: number | null; status: string }
) => {
queryClient.setQueryData([`/game/${gameId}/`], {
id: gameId,
members: [],
sandbox: false,
...game,
});
await act(() => new Promise(resolve => setTimeout(resolve, 0)));
};

let navigate: NavigateFunction;

const NavigateProbe: React.FC = () => {
navigate = useNavigate();
return null;
};

const renderLayout = () => {
const queryClient = new QueryClient();
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/game/game-1/phase/1"]}>
<NavigateProbe />
<Routes>
<Route
path="/game/:gameId/phase/:phaseId"
element={
<GameDetailLayout>
<div />
</GameDetailLayout>
}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
const invalidatedKeys = () =>
invalidateQueries.mock.calls.map(([filters]) => filters?.queryKey);
return { queryClient, invalidatedKeys };
};

describe("GameDetailLayout phase transitions", () => {
it("invalidates phase-dependent queries when the current phase advances", async () => {
const { queryClient, invalidatedKeys } = renderLayout();
await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" });

await pollGame(queryClient, "game-1", { currentPhaseId: 2, status: "active" });

expect(invalidatedKeys()).toEqual([
["/game/game-1/phase-states/"],
["/game/game-1/options/"],
["/game/game-1/phases/"],
["/game/game-1/phase/1/"],
["/game/game-1/orders/1"],
["/game/game-1/phase/2/"],
["/game/game-1/orders/2"],
]);
});

it("invalidates phase-dependent queries when the game status changes on the same phase", async () => {
const { queryClient, invalidatedKeys } = renderLayout();
await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" });

await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "completed" });

expect(invalidatedKeys()).toEqual([
["/game/game-1/phase-states/"],
["/game/game-1/options/"],
["/game/game-1/phases/"],
["/game/game-1/phase/1/"],
["/game/game-1/orders/1"],
]);
});

it("does not invalidate the polled game query", async () => {
const { queryClient, invalidatedKeys } = renderLayout();
await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" });

await pollGame(queryClient, "game-1", { currentPhaseId: 2, status: "active" });

expect(invalidatedKeys()).not.toContainEqual(["/game/game-1/"]);
});

it("does not invalidate on initial load or when a poll returns the same phase", async () => {
const { queryClient, invalidatedKeys } = renderLayout();

await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" });
await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" });

expect(invalidatedKeys()).toEqual([]);
});

it("does not treat navigating to another game as a phase transition", async () => {
const { queryClient, invalidatedKeys } = renderLayout();
await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" });

act(() => navigate("/game/game-2/phase/7"));
await pollGame(queryClient, "game-2", { currentPhaseId: 7, status: "completed" });

expect(invalidatedKeys()).toEqual([]);
});
});
51 changes: 49 additions & 2 deletions packages/web/src/components/GameDetailLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { useRequiredParams } from "@/hooks";
import { Map, Gavel, MessageCircle, Users, Info } from "lucide-react";
Expand All @@ -16,7 +17,14 @@ import { Navigation } from "@/components/Navigation";
import { GameMap } from "@/components/GameMap";
import { SafeAreaView } from "@/components/SafeAreaView";
import { OfflineBanner } from "@/components/OfflineBanner";
import { useGameRetrieve } from "@/api/generated/endpoints";
import {
useGameRetrieve,
getGameOptionsRetrieveQueryKey,
getGameOrdersListQueryKey,
getGamePhaseRetrieveQueryKey,
getGamePhasesListQueryKey,
getGamePhaseStatesListQueryKey,
} from "@/api/generated/endpoints";

const navigationItems = [
{ label: "Map", icon: Map, path: "/game/:gameId/phase/:phaseId" },
Expand Down Expand Up @@ -50,6 +58,45 @@ const GameDetailLayout: React.FC<GameDetailLayoutProps> = ({
},
});

const queryClient = useQueryClient();
const currentPhaseId = game?.currentPhaseId;
const status = game?.status;
const observedGameRef = useRef<{
gameId: string;
currentPhaseId: number | null;
status: string;
} | null>(null);

useEffect(() => {
if (currentPhaseId === undefined || status === undefined) return;
const previous = observedGameRef.current;
observedGameRef.current = { gameId, currentPhaseId, status };
if (
!previous ||
previous.gameId !== gameId ||
(previous.currentPhaseId === currentPhaseId && previous.status === status)
) {
return;
}
const affectedPhaseIds = [
...new Set([previous.currentPhaseId, currentPhaseId]),
].filter((id): id is number => id !== null);

const queryKeys = [
getGamePhaseStatesListQueryKey(gameId),
getGameOptionsRetrieveQueryKey(gameId),
getGamePhasesListQueryKey(gameId),
...affectedPhaseIds.flatMap(id => [
getGamePhaseRetrieveQueryKey(gameId, id),
getGameOrdersListQueryKey(gameId, id),
]),
];

void Promise.all(
queryKeys.map(queryKey => queryClient.invalidateQueries({ queryKey }))
);
}, [gameId, currentPhaseId, status, queryClient]);

const [searchParams] = useSearchParams();

const navItems = useMemo(() => {
Expand Down
54 changes: 52 additions & 2 deletions packages/web/src/components/GameMap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ const {
mockToastError,
mockWizardReset,
mockMapView,
mockUseOrderWizard,
mockUseGameOptionsRetrieve,
} = vi.hoisted(() => ({
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
mockWizardReset: vi.fn(),
mockMapView: vi.fn(),
mockUseOrderWizard: vi.fn(),
mockUseGameOptionsRetrieve: vi.fn(),
}));

vi.mock("sonner", () => ({
Expand Down Expand Up @@ -59,7 +63,10 @@ function buildIdleWizard(): WizardState {
}

vi.mock("@/hooks/useOrderWizard", () => ({
useOrderWizard: () => mockWizardState,
useOrderWizard: (...args: unknown[]) => {
mockUseOrderWizard(...args);
return mockWizardState;
},
}));

let mockMutateAsync = vi.fn();
Expand All @@ -73,7 +80,10 @@ vi.mock("@/api/generated/endpoints", () => ({
useVariantsRetrieve: () => ({ data: mockRetrievedVariant }),
useGamePhaseRetrieve: () => ({ data: mockPhase }),
useGameOrdersList: () => ({ data: mockExistingOrders }),
useGameOptionsRetrieve: () => ({ data: { orders: [], fieldOrder: {} } }),
useGameOptionsRetrieve: (...args: unknown[]) => {
mockUseGameOptionsRetrieve(...args);
return { data: { orders: [mockOrderOption], fieldOrder: {} } };
},
useGameOrdersCreate: () => ({ mutateAsync: mockMutateAsync }),
getGameOrdersListQueryKey: (gameId: string, phaseId: number) => [
`/game/${gameId}/orders/${phaseId}`,
Expand All @@ -88,6 +98,8 @@ vi.mock("@/utils/provinces", () => ({
}));

// --- Fixture data ---
const mockOrderOption = { source: { id: "lon", label: "London" } };

const england = { name: "England", color: "rgb(255,0,0)" };

const makeProvince = (id: string) => ({
Expand Down Expand Up @@ -231,6 +243,8 @@ describe("GameMap", () => {
mockPublishedVariants = [mockVariant];
mockRetrievedVariant = undefined;
mockGame.members = [];
mockGame.currentPhaseId = 1;
mockGame.status = "active";
});

it("stops shading a nation in civil disorder once its member has been replaced", async () => {
Expand Down Expand Up @@ -259,6 +273,42 @@ describe("GameMap", () => {
expect(props.variant.id).toBe("standard");
});

describe("order entry", () => {
it("enables and uses order options on the current phase", async () => {
render(gameMapJsx());

await waitFor(() => expect(mockMapView).toHaveBeenCalled());
expect(mockUseGameOptionsRetrieve).toHaveBeenLastCalledWith("game-1", {
query: { enabled: true },
});
expect(mockUseOrderWizard.mock.calls.at(-1)?.[0]).toEqual([mockOrderOption]);
});

it("disables and ignores order options on a phase that is no longer current", async () => {
mockGame.currentPhaseId = 2;

render(gameMapJsx());

await waitFor(() => expect(mockMapView).toHaveBeenCalled());
expect(mockUseGameOptionsRetrieve).toHaveBeenLastCalledWith("game-1", {
query: { enabled: false },
});
expect(mockUseOrderWizard.mock.calls.at(-1)?.[0]).toEqual([]);
});

it("disables and ignores order options once the game is completed", async () => {
mockGame.status = "completed";

render(gameMapJsx());

await waitFor(() => expect(mockMapView).toHaveBeenCalled());
expect(mockUseGameOptionsRetrieve).toHaveBeenLastCalledWith("game-1", {
query: { enabled: false },
});
expect(mockUseOrderWizard.mock.calls.at(-1)?.[0]).toEqual([]);
});
});

describe("optimistic order rendering", () => {
it("passes pending order to the map immediately when wizard completes", async () => {
let resolveOrder!: (order: Order) => void;
Expand Down
9 changes: 7 additions & 2 deletions packages/web/src/components/GameMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type Order,
} from "../api/generated/endpoints";
import { useOrderWizard } from "../hooks/useOrderWizard";
import { canEnterOrdersForPhase } from "../utils/orderEntry";

function useBanner(duration = 3000) {
const [message, setMessage] = useState<string | null>(null);
Expand Down Expand Up @@ -75,7 +76,11 @@ const GameMap: React.FC = () => {
const { data: variants } = useVariantsList();
const { data: phase } = useGamePhaseRetrieve(gameId, selectedPhase);
const { data: orders } = useGameOrdersList(gameId, selectedPhase);
const { data: optionsData } = useGameOptionsRetrieve(gameId);
const canEnterOrders =
!!game && !!phase && canEnterOrdersForPhase(game, phase, selectedPhase);
const { data: optionsData } = useGameOptionsRetrieve(gameId, {
query: { enabled: canEnterOrders },
});

const publishedVariant = variants?.find((v) => v.id === game?.variantId);
const { data: fetchedVariant } = useVariantsRetrieve(
Expand All @@ -94,7 +99,7 @@ const GameMap: React.FC = () => {
const createOrderMutation = useGameOrdersCreate();

const wizard = useOrderWizard(
optionsData?.orders ?? [],
canEnterOrders ? (optionsData?.orders ?? []) : [],
optionsData?.fieldOrder ?? {}
);

Expand Down
Loading
Loading