From 237a6f02f9bcd1a7cd2ac731b82b2a9dcf2c8a87 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:05:23 +0000 Subject: [PATCH 1/3] Invalidate current-phase resources on phase transitions /game//phase-states/ and /game//options/ are stable URLs whose contents change when the server resolves a phase. GameDetailLayout now watches the polled game's currentPhaseId and status and, when either changes for the same game, invalidates phase states, options, the phase list, and the previous/current phase and order queries. Generated query keys are unchanged. A shared canEnterOrdersForPhase predicate gates order entry on the map, delete and confirm on the orders screen, and confirm on the map screen, so a phase that is historical, processing, completed or no longer current cannot create, delete or confirm orders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUiNKjR45DXgsqFDw3EPks --- .../src/components/GameDetailLayout.test.tsx | 140 ++++++++++++++++++ .../web/src/components/GameDetailLayout.tsx | 46 +++++- packages/web/src/components/GameMap.test.tsx | 54 ++++++- packages/web/src/components/GameMap.tsx | 9 +- .../src/screens/GameDetail/MapScreen.test.tsx | 22 +++ .../web/src/screens/GameDetail/MapScreen.tsx | 4 +- .../screens/GameDetail/OrdersScreen.test.tsx | 27 ++++ .../src/screens/GameDetail/OrdersScreen.tsx | 6 +- packages/web/src/utils/orderEntry.test.ts | 29 ++++ packages/web/src/utils/orderEntry.ts | 12 ++ 10 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/components/GameDetailLayout.test.tsx create mode 100644 packages/web/src/utils/orderEntry.test.ts create mode 100644 packages/web/src/utils/orderEntry.ts diff --git a/packages/web/src/components/GameDetailLayout.test.tsx b/packages/web/src/components/GameDetailLayout.test.tsx new file mode 100644 index 000000000..0e4060bd2 --- /dev/null +++ b/packages/web/src/components/GameDetailLayout.test.tsx @@ -0,0 +1,140 @@ +import { act, render } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } 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))); +}; + +const renderLayout = () => { + const queryClient = new QueryClient(); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + const router = createMemoryRouter( + [ + { + path: "/game/:gameId/phase/:phaseId", + element: ( + +
+ + ), + }, + ], + { initialEntries: ["/game/game-1/phase/1"] } + ); + render( + + + + ); + const invalidatedKeys = () => + invalidateQueries.mock.calls.map(([filters]) => filters?.queryKey); + return { queryClient, router, 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, router, invalidatedKeys } = renderLayout(); + await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" }); + + await act(() => router.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 3e784e1b1..b7019420c 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,40 @@ 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 queryKeys = [ + getGamePhaseStatesListQueryKey(gameId), + getGameOptionsRetrieveQueryKey(gameId), + getGamePhasesListQueryKey(gameId), + ...[...new Set([previous.currentPhaseId, currentPhaseId])] + .filter((id): id is number => id !== null) + .flatMap(id => [ + getGamePhaseRetrieveQueryKey(gameId, id), + getGameOrdersListQueryKey(gameId, id), + ]), + ]; + queryKeys.forEach(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 dd0ff246c..54acaea73 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 b3f3fa5fc..136398692 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 657d05404..2f741eef2 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 ee1df001f..684744134 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 2e97bb63b..4812d3a4d 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 54898f732..32955c1f8 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 000000000..e12bc4c9c --- /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 000000000..5b5452aec --- /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 }; From 4c26589a58306944bbee14947ffb237bd8352b4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:08:36 +0000 Subject: [PATCH 2/3] Drive GameDetailLayout test navigation through MemoryRouter createMemoryRouter's navigate builds a fetch Request, which Node 24's undici rejects when given jsdom's AbortSignal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUiNKjR45DXgsqFDw3EPks --- .../src/components/GameDetailLayout.test.tsx | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/web/src/components/GameDetailLayout.test.tsx b/packages/web/src/components/GameDetailLayout.test.tsx index 0e4060bd2..3c2ee27c5 100644 --- a/packages/web/src/components/GameDetailLayout.test.tsx +++ b/packages/web/src/components/GameDetailLayout.test.tsx @@ -1,5 +1,5 @@ import { act, render } from "@testing-library/react"; -import { createMemoryRouter, RouterProvider } from "react-router"; +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"; @@ -51,30 +51,36 @@ const pollGame = async ( 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"); - const router = createMemoryRouter( - [ - { - path: "/game/:gameId/phase/:phaseId", - element: ( - -
- - ), - }, - ], - { initialEntries: ["/game/game-1/phase/1"] } - ); render( - + + + + +
+ + } + /> + + ); const invalidatedKeys = () => invalidateQueries.mock.calls.map(([filters]) => filters?.queryKey); - return { queryClient, router, invalidatedKeys }; + return { queryClient, invalidatedKeys }; }; describe("GameDetailLayout phase transitions", () => { @@ -129,10 +135,10 @@ describe("GameDetailLayout phase transitions", () => { }); it("does not treat navigating to another game as a phase transition", async () => { - const { queryClient, router, invalidatedKeys } = renderLayout(); + const { queryClient, invalidatedKeys } = renderLayout(); await pollGame(queryClient, "game-1", { currentPhaseId: 1, status: "active" }); - await act(() => router.navigate("/game/game-2/phase/7")); + act(() => navigate("/game/game-2/phase/7")); await pollGame(queryClient, "game-2", { currentPhaseId: 7, status: "completed" }); expect(invalidatedKeys()).toEqual([]); From b66ef57726a5b973196813f7b57e35556e395410 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:16:07 +0000 Subject: [PATCH 3/3] Name the affected phase ids in the phase transition observer Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUiNKjR45DXgsqFDw3EPks --- .../web/src/components/GameDetailLayout.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/web/src/components/GameDetailLayout.tsx b/packages/web/src/components/GameDetailLayout.tsx index b7019420c..12c78c0e6 100644 --- a/packages/web/src/components/GameDetailLayout.tsx +++ b/packages/web/src/components/GameDetailLayout.tsx @@ -78,18 +78,23 @@ const GameDetailLayout: React.FC = ({ ) { return; } + const affectedPhaseIds = [ + ...new Set([previous.currentPhaseId, currentPhaseId]), + ].filter((id): id is number => id !== null); + const queryKeys = [ getGamePhaseStatesListQueryKey(gameId), getGameOptionsRetrieveQueryKey(gameId), getGamePhasesListQueryKey(gameId), - ...[...new Set([previous.currentPhaseId, currentPhaseId])] - .filter((id): id is number => id !== null) - .flatMap(id => [ - getGamePhaseRetrieveQueryKey(gameId, id), - getGameOrdersListQueryKey(gameId, id), - ]), + ...affectedPhaseIds.flatMap(id => [ + getGamePhaseRetrieveQueryKey(gameId, id), + getGameOrdersListQueryKey(gameId, id), + ]), ]; - queryKeys.forEach(queryKey => queryClient.invalidateQueries({ queryKey })); + + void Promise.all( + queryKeys.map(queryKey => queryClient.invalidateQueries({ queryKey })) + ); }, [gameId, currentPhaseId, status, queryClient]); const [searchParams] = useSearchParams();