From 5d80ac353b6703efdde37e4685919dadbd3844c1 Mon Sep 17 00:00:00 2001 From: "parth.mishra" Date: Wed, 22 Jul 2026 23:37:49 -0400 Subject: [PATCH] test(client): cover mobile hand release wiring --- .../PlayerHand.mobileGesture.test.tsx | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 client/src/components/hand/__tests__/PlayerHand.mobileGesture.test.tsx diff --git a/client/src/components/hand/__tests__/PlayerHand.mobileGesture.test.tsx b/client/src/components/hand/__tests__/PlayerHand.mobileGesture.test.tsx new file mode 100644 index 0000000000..cb1b8fbee9 --- /dev/null +++ b/client/src/components/hand/__tests__/PlayerHand.mobileGesture.test.tsx @@ -0,0 +1,240 @@ +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { GameAction } from "../../../adapter/types.ts"; +import { useGameStore } from "../../../stores/gameStore.ts"; +import { useMultiplayerStore } from "../../../stores/multiplayerStore.ts"; +import { usePreferencesStore } from "../../../stores/preferencesStore.ts"; +import { useUiStore } from "../../../stores/uiStore.ts"; +import { gameObjectFactory } from "../../../test/factories/gameObjectFactory.ts"; +import { gameStateFactory } from "../../../test/factories/gameStateFactory.ts"; +import { setGameStoreForTest } from "../../../test/helpers/gameStoreHelpers.ts"; +import { PlayerHand } from "../PlayerHand.tsx"; + +const { mockDispatchAction } = vi.hoisted(() => ({ + mockDispatchAction: vi.fn(), +})); + +vi.mock("../../../game/dispatch.ts", () => ({ + dispatchAction: mockDispatchAction, +})); + +vi.mock("../../../hooks/useCardImage.ts", () => ({ + useCardImage: () => ({ + src: "card.png", + isLoading: false, + isRotated: false, + isFlip: false, + }), +})); + +function rect(left: number, right: number): DOMRect { + return { + bottom: 180, + height: 140, + left, + right, + top: 40, + width: right - left, + x: left, + y: 40, + toJSON: () => ({}), + } as DOMRect; +} + +function renderMobileHand(actions: GameAction[]) { + const card = gameObjectFactory + .land() + .inHand() + .withId(311) + .named("Mobile Test Land") + .build(); + const gameState = gameStateFactory + .withPlayers({ id: 0, hand: [card.id] }, 1) + .withObjects(card) + .priority(0) + .build(); + + setGameStoreForTest({ + gameState, + waitingFor: gameState.waiting_for, + legalActions: actions, + legalActionsByObject: { [card.id]: actions }, + gameMode: "local", + spellCosts: {}, + }); + + const rendered = render(); + const hand = rendered.container.firstElementChild as HTMLElement | null; + const source = rendered.container.querySelector( + `[data-hand-card][data-object-id="${card.id}"]`, + ); + expect(hand).not.toBeNull(); + expect(source).not.toBeNull(); + if (!hand || !source) throw new Error("PlayerHand did not render its mobile hand surface"); + + hand.getBoundingClientRect = () => rect(0, 200); + source.getBoundingClientRect = () => rect(0, 100); + return { card, hand, source }; +} + +function holdLiftAndRelease( + hand: HTMLElement, + source: HTMLElement, + pointerId: number, +) { + fireEvent.pointerDown(hand, { + button: 0, + clientX: 40, + clientY: 100, + isPrimary: true, + pointerId, + pointerType: "touch", + }); + act(() => vi.advanceTimersByTime(400)); + expect(source).toHaveAttribute("data-hand-touch-active", "true"); + + fireEvent.pointerMove(hand, { + clientX: 52, + clientY: 10, + isPrimary: true, + pointerId, + pointerType: "touch", + }); + fireEvent.pointerUp(hand, { + button: 0, + clientX: 52, + clientY: 10, + isPrimary: true, + pointerId, + pointerType: "touch", + }); +} + +const originalInnerWidth = window.innerWidth; + +beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 390, + }); + useMultiplayerStore.setState({ activePlayerId: null, isSpectator: false }); + usePreferencesStore.setState({ handSort: "none" }); + useUiStore.setState({ + handFilter: "none", + mobileHandGesture: null, + mobileHandOpen: false, + pendingAbilityChoice: null, + }); +}); + +afterEach(() => { + cleanup(); + useGameStore.setState({ + gameState: null, + waitingFor: null, + legalActions: [], + legalActionsByObject: {}, + gameMode: null, + spellCosts: {}, + }); + useUiStore.setState({ + handFilter: "none", + mobileHandGesture: null, + mobileHandOpen: false, + pendingAbilityChoice: null, + }); + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: originalInnerWidth, + }); + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe("PlayerHand mobile release-to-cast wiring", () => { + it("dispatches the one direct engine-provided play action", () => { + const playLandAction: GameAction = { + type: "PlayLand", + data: { object_id: 311, card_id: 311 }, + }; + const { hand, source } = renderMobileHand([playLandAction]); + + holdLiftAndRelease(hand, source, 41); + + expect(mockDispatchAction).toHaveBeenCalledOnce(); + expect(mockDispatchAction).toHaveBeenCalledWith(playLandAction); + expect(useUiStore.getState().mobileHandOpen).toBe(false); + }); + + it("keeps an ambiguous action bucket on the tap-to-open hand-modal path", () => { + const playLandAction: GameAction = { + type: "PlayLand", + data: { object_id: 311, card_id: 311 }, + }; + const alternateAction: GameAction = { + type: "ActivateAbility", + data: { source_id: 311, ability_index: 0 }, + }; + const { hand } = renderMobileHand([ + playLandAction, + alternateAction, + ]); + + fireEvent.pointerDown(hand, { + button: 0, + clientX: 40, + clientY: 100, + isPrimary: true, + pointerId: 42, + pointerType: "touch", + }); + act(() => vi.advanceTimersByTime(400)); + fireEvent.pointerMove(hand, { + clientX: 52, + clientY: 10, + isPrimary: true, + pointerId: 42, + pointerType: "touch", + }); + + expect(useUiStore.getState().mobileHandGesture).toMatchObject({ + objectId: 311, + phase: "drag", + playable: true, + castReady: false, + }); + + fireEvent.pointerUp(hand, { + button: 0, + clientX: 52, + clientY: 10, + isPrimary: true, + pointerId: 42, + pointerType: "touch", + }); + expect(mockDispatchAction).not.toHaveBeenCalled(); + + fireEvent.pointerDown(hand, { + button: 0, + clientX: 40, + clientY: 100, + isPrimary: true, + pointerId: 43, + pointerType: "touch", + }); + fireEvent.pointerUp(hand, { + button: 0, + clientX: 40, + clientY: 100, + isPrimary: true, + pointerId: 43, + pointerType: "touch", + }); + fireEvent.click(hand); + + expect(useUiStore.getState().mobileHandOpen).toBe(true); + expect(mockDispatchAction).not.toHaveBeenCalled(); + }); +});