diff --git a/packages/web/src/components/GameDetailLayout.test.tsx b/packages/web/src/components/GameDetailLayout.test.tsx new file mode 100644 index 00000000..3c2ee27c --- /dev/null +++ b/packages/web/src/components/GameDetailLayout.test.tsx @@ -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(); + 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( + + + + + +
+ + } + /> + + + + ); + 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([]); + }); +}); diff --git a/packages/web/src/components/GameDetailLayout.tsx b/packages/web/src/components/GameDetailLayout.tsx index 3e784e1b..12c78c0e 100644 --- a/packages/web/src/components/GameDetailLayout.tsx +++ b/packages/web/src/components/GameDetailLayout.tsx @@ -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"; @@ -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" }, @@ -50,6 +58,45 @@ const GameDetailLayout: React.FC = ({ }, }); + 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(() => { diff --git a/packages/web/src/components/GameMap.test.tsx b/packages/web/src/components/GameMap.test.tsx index dd0ff246..54acaea7 100644 --- a/packages/web/src/components/GameMap.test.tsx +++ b/packages/web/src/components/GameMap.test.tsx @@ -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", () => ({ @@ -59,7 +63,10 @@ function buildIdleWizard(): WizardState { } vi.mock("@/hooks/useOrderWizard", () => ({ - useOrderWizard: () => mockWizardState, + useOrderWizard: (...args: unknown[]) => { + mockUseOrderWizard(...args); + return mockWizardState; + }, })); let mockMutateAsync = vi.fn(); @@ -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}`, @@ -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) => ({ @@ -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 () => { @@ -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; diff --git a/packages/web/src/components/GameMap.tsx b/packages/web/src/components/GameMap.tsx index b3f3fa5f..13639869 100644 --- a/packages/web/src/components/GameMap.tsx +++ b/packages/web/src/components/GameMap.tsx @@ -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(null); @@ -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( @@ -94,7 +99,7 @@ const GameMap: React.FC = () => { const createOrderMutation = useGameOrdersCreate(); const wizard = useOrderWizard( - optionsData?.orders ?? [], + canEnterOrders ? (optionsData?.orders ?? []) : [], optionsData?.fieldOrder ?? {} ); diff --git a/packages/web/src/screens/GameDetail/MapScreen.test.tsx b/packages/web/src/screens/GameDetail/MapScreen.test.tsx index 657d0540..2f741eef 100644 --- a/packages/web/src/screens/GameDetail/MapScreen.test.tsx +++ b/packages/web/src/screens/GameDetail/MapScreen.test.tsx @@ -57,6 +57,7 @@ describe("MapScreen confirm orders button", () => { mockGameData.mockReturnValue({ id: "1", status: "active", + currentPhaseId: 5, sandbox: false, phaseConfirmed: false, members: [{ id: 1, isCurrentUser: true, civilDisorder: false }], @@ -81,6 +82,7 @@ describe("MapScreen confirm orders button", () => { mockGameData.mockReturnValue({ id: "1", status: "active", + currentPhaseId: 5, sandbox: false, phaseConfirmed: true, members: [{ id: 1, isCurrentUser: true, civilDisorder: false }], @@ -95,10 +97,29 @@ describe("MapScreen confirm orders button", () => { expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument(); }); + it("hides the button on a phase that is no longer the game's current phase", () => { + mockGameData.mockReturnValue({ ...mockGameData(), currentPhaseId: 6 }); + renderScreen(); + expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument(); + }); + + it("hides the button while the phase is processing", () => { + mockPhaseData.mockReturnValue({ status: "processing" }); + renderScreen(); + expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument(); + }); + + it("hides the button once the game is completed", () => { + mockGameData.mockReturnValue({ ...mockGameData(), status: "completed" }); + renderScreen(); + expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument(); + }); + it("hides the button for a spectator", () => { mockGameData.mockReturnValue({ id: "1", status: "active", + currentPhaseId: 5, sandbox: false, phaseConfirmed: false, members: [{ id: 2, isCurrentUser: false, civilDisorder: false }], @@ -111,6 +132,7 @@ describe("MapScreen confirm orders button", () => { mockGameData.mockReturnValue({ id: "1", status: "active", + currentPhaseId: 5, sandbox: false, phaseConfirmed: false, members: [{ id: 1, isCurrentUser: true, civilDisorder: true }], diff --git a/packages/web/src/screens/GameDetail/MapScreen.tsx b/packages/web/src/screens/GameDetail/MapScreen.tsx index ee1df001..68474413 100644 --- a/packages/web/src/screens/GameDetail/MapScreen.tsx +++ b/packages/web/src/screens/GameDetail/MapScreen.tsx @@ -8,6 +8,7 @@ import { GameMap } from "@/components/GameMap"; import { ConfirmOrdersButton } from "@/components/ConfirmOrdersButton"; import { useRequiredParams } from "@/hooks"; import { countOrders } from "@/utils/orderCount"; +import { canEnterOrdersForPhase } from "@/utils/orderEntry"; import { useGameOrdersListSuspense, useGamePhaseRetrieveSuspense, @@ -33,8 +34,7 @@ const MapConfirmOrders: React.FC = () => { currentMember !== undefined && !currentMember.civilDisorder && !game.sandbox && - game.status === "active" && - phase.status === "active"; + canEnterOrdersForPhase(game, phase, selectedPhase); const count = countOrders( Array.isArray(phaseStates) ? phaseStates : [], diff --git a/packages/web/src/screens/GameDetail/OrdersScreen.test.tsx b/packages/web/src/screens/GameDetail/OrdersScreen.test.tsx index 2e97bb63..4812d3a4 100644 --- a/packages/web/src/screens/GameDetail/OrdersScreen.test.tsx +++ b/packages/web/src/screens/GameDetail/OrdersScreen.test.tsx @@ -103,6 +103,7 @@ describe("OrdersScreen civil disorder handling", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -118,6 +119,7 @@ describe("OrdersScreen civil disorder handling", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -133,6 +135,7 @@ describe("OrdersScreen civil disorder handling", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -154,6 +157,7 @@ describe("OrdersScreen civil disorder handling", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -169,6 +173,7 @@ describe("OrdersScreen civil disorder handling", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -200,6 +205,7 @@ describe("OrdersScreen confirm orders button", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "fixed_time", phaseConfirmed: false, @@ -215,6 +221,7 @@ describe("OrdersScreen confirm orders button", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -238,6 +245,7 @@ describe("OrdersScreen spectating", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -272,6 +280,7 @@ describe("OrdersScreen resilience to malformed list data", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -337,6 +346,7 @@ describe("OrdersScreen named coast display", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -416,6 +426,7 @@ describe("OrdersScreen delete order button", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, @@ -464,6 +475,21 @@ describe("OrdersScreen delete order button", () => { expect(screen.getByLabelText(/Delete order for/)).not.toBeDisabled(); }); + it.each([ + ["is no longer the game's current phase", { currentPhaseId: 2 }, {}], + ["is processing", {}, { status: "processing" }], + ["belongs to a completed game", { status: "completed" }, {}], + ])("hides delete and confirm when the phase %s", (_, gameOverrides, phaseOverrides) => { + mockGameData.mockReturnValue({ ...mockGameData(), ...gameOverrides }); + mockPhaseData.mockReturnValue({ ...mockPhaseData(), ...phaseOverrides }); + + renderOrdersScreen(); + + expect(screen.getByText("Army London")).toBeInTheDocument(); + expect(screen.queryByLabelText(/Delete order for/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument(); + }); + it("uses a long-tailed upward arrow for movement orders", () => { mockOrdersData.mockReturnValue([ { @@ -514,6 +540,7 @@ describe("OrdersScreen no orders required (active phase, has a member)", () => { mockGameData.mockReturnValue({ variantId: "classical", status: "active", + currentPhaseId: 1, sandbox: false, deadlineMode: "duration", phaseConfirmed: false, diff --git a/packages/web/src/screens/GameDetail/OrdersScreen.tsx b/packages/web/src/screens/GameDetail/OrdersScreen.tsx index 54898f73..32955c1f 100644 --- a/packages/web/src/screens/GameDetail/OrdersScreen.tsx +++ b/packages/web/src/screens/GameDetail/OrdersScreen.tsx @@ -58,6 +58,7 @@ import { } from "@/api/generated/endpoints"; import { useGameVariant } from "@/hooks/useGameVariant"; import { countOrders } from "@/utils/orderCount"; +import { canEnterOrdersForPhase } from "@/utils/orderEntry"; import { cn } from "@/lib/utils"; type NationGroup = { @@ -441,8 +442,6 @@ const OrdersScreen: React.FC = () => { } const isActivePhase = phase.status === "active"; - const isGameFinished = - game.status === "completed" || game.status === "abandoned"; const members = Array.isArray(game.members) ? game.members : []; const safeOrders = Array.isArray(orders) ? orders : []; const safePhaseStates = Array.isArray(phaseStates) ? phaseStates : []; @@ -451,8 +450,7 @@ const OrdersScreen: React.FC = () => { const isCurrentMemberInCivilDisorder = currentMember?.civilDisorder ?? false; const canModifyOrders = !isSpectator && - isActivePhase && - !isGameFinished && + canEnterOrdersForPhase(game, phase, selectedPhase) && !isCurrentMemberInCivilDisorder; const getSupplyCenterCount = (nation: string) => { diff --git a/packages/web/src/utils/orderEntry.test.ts b/packages/web/src/utils/orderEntry.test.ts new file mode 100644 index 00000000..e12bc4c9 --- /dev/null +++ b/packages/web/src/utils/orderEntry.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { canEnterOrdersForPhase } from "./orderEntry"; + +const activeGame = { status: "active", currentPhaseId: 2 }; +const activePhase = { status: "active" } as const; + +describe("canEnterOrdersForPhase", () => { + it("allows order entry on the game's current active phase", () => { + expect(canEnterOrdersForPhase(activeGame, activePhase, 2)).toBe(true); + }); + + it("disallows order entry on a historical phase", () => { + expect(canEnterOrdersForPhase(activeGame, { status: "completed" }, 1)).toBe(false); + }); + + it("disallows order entry on a selected phase that is no longer current", () => { + expect(canEnterOrdersForPhase(activeGame, activePhase, 1)).toBe(false); + }); + + it("disallows order entry while the phase is processing", () => { + expect(canEnterOrdersForPhase(activeGame, { status: "processing" }, 2)).toBe(false); + }); + + it("disallows order entry once the game is completed", () => { + expect( + canEnterOrdersForPhase({ ...activeGame, status: "completed" }, activePhase, 2) + ).toBe(false); + }); +}); diff --git a/packages/web/src/utils/orderEntry.ts b/packages/web/src/utils/orderEntry.ts new file mode 100644 index 00000000..5b5452ae --- /dev/null +++ b/packages/web/src/utils/orderEntry.ts @@ -0,0 +1,12 @@ +import type { GameRetrieve, PhaseRetrieve } from "../api/generated/endpoints"; + +const canEnterOrdersForPhase = ( + game: Pick, + phase: Pick, + selectedPhase: number +): boolean => + game.status === "active" && + phase.status === "active" && + game.currentPhaseId === selectedPhase; + +export { canEnterOrdersForPhase };