diff --git a/webclient/package-lock.json b/webclient/package-lock.json index 15765b403..d36442220 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -55,6 +55,7 @@ "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react-swc": "^4.3.0", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", @@ -1670,6 +1671,268 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@swc/core": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.30.tgz", + "integrity": "sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.30", + "@swc/core-darwin-x64": "1.15.30", + "@swc/core-linux-arm-gnueabihf": "1.15.30", + "@swc/core-linux-arm64-gnu": "1.15.30", + "@swc/core-linux-arm64-musl": "1.15.30", + "@swc/core-linux-ppc64-gnu": "1.15.30", + "@swc/core-linux-s390x-gnu": "1.15.30", + "@swc/core-linux-x64-gnu": "1.15.30", + "@swc/core-linux-x64-musl": "1.15.30", + "@swc/core-win32-arm64-msvc": "1.15.30", + "@swc/core-win32-ia32-msvc": "1.15.30", + "@swc/core-win32-x64-msvc": "1.15.30" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.30.tgz", + "integrity": "sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.30.tgz", + "integrity": "sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.30.tgz", + "integrity": "sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.30.tgz", + "integrity": "sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.30.tgz", + "integrity": "sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.30.tgz", + "integrity": "sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.30.tgz", + "integrity": "sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.30.tgz", + "integrity": "sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.30.tgz", + "integrity": "sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.30.tgz", + "integrity": "sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.30.tgz", + "integrity": "sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.30", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.30.tgz", + "integrity": "sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2450,6 +2713,23 @@ } } }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz", + "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7", + "@swc/core": "^1.15.11" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7 || ^8" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", diff --git a/webclient/package.json b/webclient/package.json index c218ce03c..2312f5694 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -73,6 +73,7 @@ "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react-swc": "^4.3.0", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", diff --git a/webclient/src/__test-utils__/renderWithProviders.tsx b/webclient/src/__test-utils__/renderWithProviders.tsx index 10f50628a..6cef77e4d 100644 --- a/webclient/src/__test-utils__/renderWithProviders.tsx +++ b/webclient/src/__test-utils__/renderWithProviders.tsx @@ -9,12 +9,30 @@ import { initReactI18next } from 'react-i18next'; import { DndContext } from '@dnd-kit/core'; import { createTheme, ThemeProvider } from '@mui/material/styles'; -// Disables MUI's ripple animation in tests. The ripple fires a deferred -// state update after clicks/focus that would otherwise trigger a noisy -// "update to ForwardRef(TouchRipple) was not wrapped in act(...)" warning. +// Disables MUI's ripple animation AND all component transitions in tests. +// The ripple fires a deferred state update after clicks/focus that would +// trigger a noisy "update to ForwardRef(TouchRipple) was not wrapped in +// act(...)" warning. Transitions (Grow/Fade/Slide used by Menu, Dialog, +// Popover, Tooltip) default to ~225ms, which is pure wait-time in jsdom +// — every portal open paid this cost before. Zeroing `transitions.duration` +// plus the per-component `transitionDuration: 0` override belt-and-braces +// covers the full v9 surface: styled transitions read the theme; component- +// level Transition props need the defaultProps override. const testTheme = createTheme({ + transitions: { + duration: { + shortest: 0, shorter: 0, short: 0, + standard: 0, complex: 0, + enteringScreen: 0, leavingScreen: 0, + }, + create: () => 'none', + }, components: { MuiButtonBase: { defaultProps: { disableRipple: true } }, + MuiDialog: { defaultProps: { transitionDuration: 0 } }, + MuiMenu: { defaultProps: { transitionDuration: 0 } }, + MuiPopover: { defaultProps: { transitionDuration: 0 } }, + MuiTooltip: { defaultProps: { enterDelay: 0, leaveDelay: 0 } }, }, }); @@ -26,6 +44,20 @@ import { storeMiddlewareOptions } from '../store/store'; import type { RootState } from '../store/store'; import { createMockWebClient } from './mockWebClient'; +// Lazy-initialized per test file (vitest isolate: true re-evaluates module +// graph per file). Reused by every `renderWithProviders` call that doesn't +// inject its own webClient, so the ~65 vi.fn() allocations happen once per +// file instead of once per render. The global `afterEach` in setupTests.ts +// runs `vi.clearAllMocks()` which resets call history between tests without +// destroying the fn instances — exactly what we want here. +let defaultWebClient: WebClient | undefined; +function getDefaultWebClient(): WebClient { + if (!defaultWebClient) { + defaultWebClient = createMockWebClient(); + } + return defaultWebClient; +} + // Non-empty `resources` registers en-US so `resolvedLanguage` is defined; // without it MUI warns about out-of-range Select values. const testI18n = i18n.createInstance(); @@ -64,7 +96,7 @@ export function renderWithProviders( preloadedState, store = createTestStore(preloadedState), route = '/', - webClient = createMockWebClient(), + webClient = getDefaultWebClient(), ...renderOptions }: ExtendedRenderOptions = {}, ) { diff --git a/webclient/src/containers/Game/Game.orchestration.spec.tsx b/webclient/src/containers/Game/Game.orchestration.spec.tsx new file mode 100644 index 000000000..0d6c03be5 --- /dev/null +++ b/webclient/src/containers/Game/Game.orchestration.spec.tsx @@ -0,0 +1,356 @@ +// M4–M6 orchestration tests — extracted from Game.spec.tsx so they run in +// their own vitest worker slot (pool: 'threads'). Each of these goes through +// the Game.tsx state wiring between a trigger component and the dialog/menu +// it opens; individual handlers are tested in child specs. This suite pins +// the end-to-end dispatch so a regression that disconnects state from its +// consumers is caught even when both sides still pass in isolation. +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { App } from '@app/types'; + +import { createMockWebClient, makeStoreState, renderWithProviders, connectedState, makeUser } from '../../__test-utils__'; +import { + makeCard, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, + makeZoneEntry, +} from '../../store/game/__mocks__/fixtures'; +import Game from './Game'; + +// Layout pulls in LeftNav which is not under test here; stub to a no-op. +vi.mock('../Layout/Layout', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Block TurnControls' / Battlefield's Dexie-backed useSettings from firing +// an async settle after mount (would produce an unwrapped React state update). +vi.mock('../../hooks/useSettings'); + +interface BuildGameOpts { + localId: number; + opponentIds: number[]; + tableCards?: ReturnType[]; + started?: boolean; + spectator?: boolean; + judge?: boolean; + localReadyStart?: boolean; + graveCards?: ReturnType[]; +} + +function buildGame({ + localId, + opponentIds, + tableCards = [], + started = true, + spectator = false, + judge = false, + localReadyStart = false, + graveCards = [], +}: BuildGameOpts) { + const players: Record> = {}; + const playerIds = [localId, ...opponentIds]; + for (const pid of playerIds) { + players[pid] = makePlayerEntry({ + properties: makePlayerProperties({ + playerId: pid, + userInfo: makeUser({ name: `P${pid}` }), + readyStart: pid === localId ? localReadyStart : false, + }), + zones: { + [App.ZoneName.TABLE]: makeZoneEntry({ + name: App.ZoneName.TABLE, + cards: pid === localId ? tableCards : [], + cardCount: pid === localId ? tableCards.length : 0, + }), + [App.ZoneName.HAND]: makeZoneEntry({ name: App.ZoneName.HAND }), + [App.ZoneName.DECK]: makeZoneEntry({ name: App.ZoneName.DECK, cardCount: 40 }), + [App.ZoneName.GRAVE]: makeZoneEntry({ + name: App.ZoneName.GRAVE, + cards: pid === localId ? graveCards : [], + cardCount: pid === localId ? graveCards.length : 0, + }), + [App.ZoneName.EXILE]: makeZoneEntry({ name: App.ZoneName.EXILE }), + }, + }); + } + return makeStoreState({ + ...connectedState, + games: { + games: { + 1: makeGameEntry({ + localPlayerId: localId, + spectator, + judge, + started, + players, + }), + }, + }, + }); +} + +describe('Game orchestration (M4–M6)', () => { + it('Roll Die: TurnControls → RollDieDialog → rollDie dispatch', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /roll die/i })); + const sides = await screen.findByLabelText('Sides') as HTMLInputElement; + const count = screen.getByLabelText('Count') as HTMLInputElement; + fireEvent.change(sides, { target: { value: '20' } }); + fireEvent.change(count, { target: { value: '2' } }); + fireEvent.click(screen.getByRole('button', { name: /^roll$/i })); + + expect(webClient.request.game.rollDie).toHaveBeenCalledWith(1, { sides: 20, count: 2 }); + }); + + it('Kick: TurnControls host menu → kickFromGame with chosen opponent', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + // localId 1 is the host by fixture default (hostId: 1). + preloadedState: buildGame({ localId: 1, opponentIds: [2, 3] }), + webClient, + }); + + fireEvent.click(screen.getByRole('button', { name: /kick/i })); + // P3 also appears in the OpponentSelector; pick the one inside the + // MUI Menu popup. + const menuItem = (await screen.findAllByText('P3')).find((el) => el.closest('[role="menuitem"]')); + fireEvent.click(menuItem!); + + expect(webClient.request.game.kickFromGame).toHaveBeenCalledWith(1, { playerId: 3 }); + }); + + it('Create Token: PlayerContextMenu → CreateTokenDialog → createToken', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.contextMenu(screen.getByTestId('player-info-1')); + fireEvent.click(await screen.findByText('Create token…')); + + const nameInput = await screen.findByLabelText('Token name'); + fireEvent.change(nameInput, { target: { value: 'Goblin' } }); + fireEvent.click(screen.getByRole('button', { name: /^create$/i })); + + expect(webClient.request.game.createToken).toHaveBeenCalledWith( + 1, + expect.objectContaining({ cardName: 'Goblin', zone: App.ZoneName.TABLE }), + ); + }); + + it('Mulligan same-size: HandContextMenu → mulligan with current hand count', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + // Seed the local hand with 5 cards so "same size" sends number: 5. + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 5 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 5, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(same size\)/i)); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 5 }); + }); + + it('Mulligan choose-size: negative input is translated to handSize + input', async () => { + // Desktop's actMulligan (player_actions.cpp:308-354) treats 0 and + // negative inputs as "relative to current hand size" before dispatching + // Command_Mulligan. Regression guard for that convention. + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 7, + }); + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, cards: [], cardCount: 53, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); + + expect( + await screen.findByText('0 and lower are in comparison to current hand size.'), + ).toBeInTheDocument(); + + const input = screen.getByLabelText('New hand size'); + fireEvent.change(input, { target: { value: '-1' } }); + fireEvent.click(screen.getByRole('button', { name: /ok/i })); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 6 }); + }); + + it('Mulligan choose-size: positive integer passes through unchanged', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 7, + }); + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, cards: [], cardCount: 53, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); + + const input = await screen.findByLabelText('New hand size'); + fireEvent.change(input, { target: { value: '4' } }); + fireEvent.click(screen.getByRole('button', { name: /ok/i })); + + expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 4 }); + }); + + it('Arrow-from-hand auto-plays the source card instead of sending a stale createArrow', async () => { + // Desktop parity (card_item.cpp:243-250): dragging an arrow from a + // local-hand card to a target outside the hand auto-plays the card. + // The server re-keys the card id on the move, so sending createArrow + // with the old hand cardId would be rejected. We resolve this as a + // play-card intent and skip the arrow command. + const webClient = createMockWebClient(); + const state = buildGame({ + localId: 1, + opponentIds: [2], + tableCards: [makeCard({ id: 50, name: 'Bear' })], + }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: [makeCard({ id: 10, name: 'Lightning Bolt' })], + cardCount: 1, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + const handCard = document.querySelector('[data-card-zone="hand"][data-card-id="10"]')!; + fireEvent.contextMenu(handCard); + + const drawArrowItem = await screen.findByText('Draw arrow from here'); + fireEvent.click(drawArrowItem); + + const tableCard = document.querySelector('[data-card-zone="table"][data-card-id="50"]')!; + fireEvent.click(tableCard); + + await waitFor(() => { + expect(webClient.request.game.moveCard).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + startPlayerId: 1, + startZone: App.ZoneName.HAND, + targetPlayerId: 1, + targetZone: App.ZoneName.TABLE, + cardsToMove: { card: [{ cardId: 10 }] }, + }), + ); + }); + expect(webClient.request.game.createArrow).not.toHaveBeenCalled(); + }); + + it('Mulligan choose-size: value outside [-handSize, handSize+deckSize] is rejected', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ + name: App.ZoneName.HAND, + cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), + cardCount: 7, + }); + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, cards: [], cardCount: 53, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('hand-zone')); + fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); + + const input = await screen.findByLabelText('New hand size'); + fireEvent.change(input, { target: { value: '-99' } }); + fireEvent.click(screen.getByRole('button', { name: /ok/i })); + + expect(webClient.request.game.mulligan).not.toHaveBeenCalled(); + expect(screen.getByText(/between -7 and 60/i)).toBeInTheDocument(); + }); + + it('Sideboard: PlayerContextMenu → SideboardDialog → setSideboardPlan with the accumulated moveList', async () => { + const webClient = createMockWebClient(); + const state = buildGame({ localId: 1, opponentIds: [2] }); + const localPlayer = state.games.games[1].players[1]; + localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ + name: App.ZoneName.DECK, + cards: [makeCard({ id: 100, name: 'Island' })], + cardCount: 1, + }); + localPlayer.zones[App.ZoneName.SIDEBOARD] = makeZoneEntry({ + name: App.ZoneName.SIDEBOARD, + cards: [makeCard({ id: 200, name: 'Counterspell' })], + cardCount: 1, + }); + renderWithProviders(, { preloadedState: state, webClient }); + + fireEvent.contextMenu(screen.getByTestId('player-info-1')); + fireEvent.click(await screen.findByText(/view sideboard/i)); + fireEvent.click(await screen.findByRole('button', { name: /move Island to sideboard/i })); + fireEvent.click(screen.getByRole('button', { name: /apply plan/i })); + + expect(webClient.request.game.setSideboardPlan).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + moveList: [ + { cardName: 'Island', startZone: App.ZoneName.DECK, targetZone: App.ZoneName.SIDEBOARD }, + ], + }), + ); + }); + + it('Sideboard lock: toggling Lock sideboard dispatches setSideboardLock', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.contextMenu(screen.getByTestId('player-info-1')); + fireEvent.click(await screen.findByText(/view sideboard/i)); + fireEvent.click(await screen.findByLabelText('Lock sideboard')); + + expect(webClient.request.game.setSideboardLock).toHaveBeenCalledWith(1, { locked: true }); + }); + + it('changeZoneProperties: toggling "Always reveal top card" on local deck dispatches the command', async () => { + const webClient = createMockWebClient(); + renderWithProviders(, { + preloadedState: buildGame({ localId: 1, opponentIds: [2] }), + webClient, + }); + + fireEvent.contextMenu( + screen + .getByTestId('player-board-1') + .querySelector(`[data-testid="zone-stack-${App.ZoneName.DECK}"]`)!, + ); + fireEvent.click(await screen.findByText(/always reveal top card/i)); + + expect(webClient.request.game.changeZoneProperties).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + zoneName: App.ZoneName.DECK, + alwaysRevealTopCard: true, + }), + ); + }); +}); diff --git a/webclient/src/containers/Game/Game.spec.tsx b/webclient/src/containers/Game/Game.spec.tsx index 43f15d945..b65e081c4 100644 --- a/webclient/src/containers/Game/Game.spec.tsx +++ b/webclient/src/containers/Game/Game.spec.tsx @@ -458,299 +458,9 @@ describe('Game container', () => { }); }); - // M4–M6 orchestration — each of these goes through Game.tsx state wiring - // between a trigger component and the dialog/menu it opens. Individual - // handlers are tested in child specs; this suite pins the end-to-end - // dispatch so a regression that disconnects state from its consumers is - // caught even when both sides still pass in isolation. - describe('Orchestration (M4–M6)', () => { - // Each test renders the full Game container (DndContext + CardRegistry + - // both player boards + preview panel) and drives MUI portal transitions - // for Menu → Dialog flows. Cold jsdom render plus two portal transitions - // routinely pushes a single test past vitest's 5s default under worker - // contention. Every test in this block passes 15000ms explicitly as the - // 3rd arg to `it(...)` — vi.setConfig in beforeAll/beforeEach didn't take - // effect because the per-test timeout is captured at describe-registration - // time, not at run time. - const ORCHESTRATION_TIMEOUT_MS = 15000; + // M4–M6 orchestration tests live in Game.orchestration.spec.tsx — that + // file pins the end-to-end dispatch flows (dialog/menu → command) that go + // through Game.tsx state wiring. Splitting them out lets vitest's threads + // pool run them in parallel with the unit-style tests in this file. - it('Roll Die: TurnControls → RollDieDialog → rollDie dispatch', async () => { - const webClient = createMockWebClient(); - renderWithProviders(, { - preloadedState: buildGame({ localId: 1, opponentIds: [2] }), - webClient, - }); - - fireEvent.click(screen.getByRole('button', { name: /roll die/i })); - // Dialog opens via MUI portal+transition; await its inputs before - // interacting to avoid flakes under worker contention. - const sides = await screen.findByLabelText('Sides') as HTMLInputElement; - const count = screen.getByLabelText('Count') as HTMLInputElement; - fireEvent.change(sides, { target: { value: '20' } }); - fireEvent.change(count, { target: { value: '2' } }); - fireEvent.click(screen.getByRole('button', { name: /^roll$/i })); - - expect(webClient.request.game.rollDie).toHaveBeenCalledWith(1, { sides: 20, count: 2 }); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Kick: TurnControls host menu → kickFromGame with chosen opponent', async () => { - const webClient = createMockWebClient(); - renderWithProviders(, { - // localId 1 is the host by fixture default (hostId: 1). - preloadedState: buildGame({ localId: 1, opponentIds: [2, 3] }), - webClient, - }); - - fireEvent.click(screen.getByRole('button', { name: /kick/i })); - // P3 also appears in the OpponentSelector; pick the one inside the - // MUI Menu popup. findAllByText waits for the portal to mount. - const menuItem = (await screen.findAllByText('P3')).find((el) => el.closest('[role="menuitem"]')); - fireEvent.click(menuItem!); - - expect(webClient.request.game.kickFromGame).toHaveBeenCalledWith(1, { playerId: 3 }); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Create Token: PlayerContextMenu → CreateTokenDialog → createToken', async () => { - const webClient = createMockWebClient(); - renderWithProviders(, { - preloadedState: buildGame({ localId: 1, opponentIds: [2] }), - webClient, - }); - - fireEvent.contextMenu(screen.getByTestId('player-info-1')); - fireEvent.click(await screen.findByText('Create token…')); - - const nameInput = await screen.findByLabelText('Token name'); - fireEvent.change(nameInput, { target: { value: 'Goblin' } }); - fireEvent.click(screen.getByRole('button', { name: /^create$/i })); - - expect(webClient.request.game.createToken).toHaveBeenCalledWith( - 1, - expect.objectContaining({ cardName: 'Goblin', zone: App.ZoneName.TABLE }), - ); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Mulligan same-size: HandContextMenu → mulligan with current hand count', async () => { - const webClient = createMockWebClient(); - const state = buildGame({ localId: 1, opponentIds: [2] }); - // Seed the local hand with 5 cards so "same size" sends number: 5. - const localPlayer = state.games.games[1].players[1]; - localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ - name: App.ZoneName.HAND, - cards: Array.from({ length: 5 }, (_, i) => makeCard({ id: 100 + i })), - cardCount: 5, - }); - renderWithProviders(, { preloadedState: state, webClient }); - - fireEvent.contextMenu(screen.getByTestId('hand-zone')); - fireEvent.click(await screen.findByText(/take mulligan \(same size\)/i)); - - expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 5 }); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Mulligan choose-size: negative input is translated to handSize + input', async () => { - // Desktop's actMulligan (player_actions.cpp:308-354) treats 0 and - // negative inputs as "relative to current hand size" before - // dispatching Command_Mulligan. Regression guard for that convention. - const webClient = createMockWebClient(); - const state = buildGame({ localId: 1, opponentIds: [2] }); - const localPlayer = state.games.games[1].players[1]; - localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ - name: App.ZoneName.HAND, - cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), - cardCount: 7, - }); - localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ - name: App.ZoneName.DECK, - cards: [], - cardCount: 53, - }); - renderWithProviders(, { preloadedState: state, webClient }); - - fireEvent.contextMenu(screen.getByTestId('hand-zone')); - fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); - - // Helper text is visible to the user. - expect( - await screen.findByText('0 and lower are in comparison to current hand size.'), - ).toBeInTheDocument(); - - // Enter −1: server receives handSize + (−1) = 6. - const input = screen.getByLabelText('New hand size'); - fireEvent.change(input, { target: { value: '-1' } }); - fireEvent.click(screen.getByRole('button', { name: /ok/i })); - - expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 6 }); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Mulligan choose-size: positive integer passes through unchanged', async () => { - const webClient = createMockWebClient(); - const state = buildGame({ localId: 1, opponentIds: [2] }); - const localPlayer = state.games.games[1].players[1]; - localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ - name: App.ZoneName.HAND, - cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), - cardCount: 7, - }); - localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ - name: App.ZoneName.DECK, - cards: [], - cardCount: 53, - }); - renderWithProviders(, { preloadedState: state, webClient }); - - fireEvent.contextMenu(screen.getByTestId('hand-zone')); - fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); - - const input = await screen.findByLabelText('New hand size'); - fireEvent.change(input, { target: { value: '4' } }); - fireEvent.click(screen.getByRole('button', { name: /ok/i })); - - expect(webClient.request.game.mulligan).toHaveBeenCalledWith(1, { number: 4 }); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Arrow-from-hand auto-plays the source card instead of sending a stale createArrow', async () => { - // Desktop parity (card_item.cpp:243-250): dragging an arrow from a - // local-hand card to a target outside the hand auto-plays the card. - // The server re-keys the card id on the move, so sending createArrow - // with the old hand cardId would be rejected. We resolve this as a - // play-card intent and skip the arrow command. - const webClient = createMockWebClient(); - const state = buildGame({ - localId: 1, - opponentIds: [2], - tableCards: [makeCard({ id: 50, name: 'Bear' })], - }); - const localPlayer = state.games.games[1].players[1]; - localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ - name: App.ZoneName.HAND, - cards: [makeCard({ id: 10, name: 'Lightning Bolt' })], - cardCount: 1, - }); - renderWithProviders(, { preloadedState: state, webClient }); - - // Right-click the hand card to open CardContextMenu. - const handCard = document.querySelector('[data-card-zone="hand"][data-card-id="10"]')!; - fireEvent.contextMenu(handCard); - - // MUI Menu transitions in — await its content before interacting. - const drawArrowItem = await screen.findByText('Draw arrow from here'); - fireEvent.click(drawArrowItem); - - // Click a battlefield card — the handleCardClick path should detect - // the hand-source + non-hand-target combo and dispatch moveCard. - const tableCard = document.querySelector('[data-card-zone="table"][data-card-id="50"]')!; - fireEvent.click(tableCard); - - await waitFor(() => { - expect(webClient.request.game.moveCard).toHaveBeenCalledWith( - 1, - expect.objectContaining({ - startPlayerId: 1, - startZone: App.ZoneName.HAND, - targetPlayerId: 1, - targetZone: App.ZoneName.TABLE, - cardsToMove: { card: [{ cardId: 10 }] }, - }), - ); - }); - expect(webClient.request.game.createArrow).not.toHaveBeenCalled(); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Mulligan choose-size: value outside [-handSize, handSize+deckSize] is rejected', async () => { - const webClient = createMockWebClient(); - const state = buildGame({ localId: 1, opponentIds: [2] }); - const localPlayer = state.games.games[1].players[1]; - localPlayer.zones[App.ZoneName.HAND] = makeZoneEntry({ - name: App.ZoneName.HAND, - cards: Array.from({ length: 7 }, (_, i) => makeCard({ id: 100 + i })), - cardCount: 7, - }); - localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ - name: App.ZoneName.DECK, - cards: [], - cardCount: 53, - }); - renderWithProviders(, { preloadedState: state, webClient }); - - fireEvent.contextMenu(screen.getByTestId('hand-zone')); - fireEvent.click(await screen.findByText(/take mulligan \(choose size\)/i)); - - const input = await screen.findByLabelText('New hand size'); - fireEvent.change(input, { target: { value: '-99' } }); - fireEvent.click(screen.getByRole('button', { name: /ok/i })); - - expect(webClient.request.game.mulligan).not.toHaveBeenCalled(); - expect(screen.getByText(/between -7 and 60/i)).toBeInTheDocument(); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Sideboard: PlayerContextMenu → SideboardDialog → setSideboardPlan with the accumulated moveList', async () => { - const webClient = createMockWebClient(); - const state = buildGame({ localId: 1, opponentIds: [2] }); - // Seed the local deck + sideboard with distinct named cards. - const localPlayer = state.games.games[1].players[1]; - localPlayer.zones[App.ZoneName.DECK] = makeZoneEntry({ - name: App.ZoneName.DECK, - cards: [makeCard({ id: 100, name: 'Island' })], - cardCount: 1, - }); - localPlayer.zones[App.ZoneName.SIDEBOARD] = makeZoneEntry({ - name: App.ZoneName.SIDEBOARD, - cards: [makeCard({ id: 200, name: 'Counterspell' })], - cardCount: 1, - }); - renderWithProviders(, { preloadedState: state, webClient }); - - fireEvent.contextMenu(screen.getByTestId('player-info-1')); - fireEvent.click(await screen.findByText(/view sideboard/i)); - fireEvent.click(await screen.findByRole('button', { name: /move Island to sideboard/i })); - fireEvent.click(screen.getByRole('button', { name: /apply plan/i })); - - expect(webClient.request.game.setSideboardPlan).toHaveBeenCalledWith( - 1, - expect.objectContaining({ - moveList: [ - { cardName: 'Island', startZone: App.ZoneName.DECK, targetZone: App.ZoneName.SIDEBOARD }, - ], - }), - ); - }, ORCHESTRATION_TIMEOUT_MS); - - it('Sideboard lock: toggling Lock sideboard dispatches setSideboardLock', async () => { - const webClient = createMockWebClient(); - renderWithProviders(, { - preloadedState: buildGame({ localId: 1, opponentIds: [2] }), - webClient, - }); - - fireEvent.contextMenu(screen.getByTestId('player-info-1')); - fireEvent.click(await screen.findByText(/view sideboard/i)); - fireEvent.click(await screen.findByLabelText('Lock sideboard')); - - expect(webClient.request.game.setSideboardLock).toHaveBeenCalledWith(1, { locked: true }); - }, ORCHESTRATION_TIMEOUT_MS); - - it('changeZoneProperties: toggling "Always reveal top card" on local deck dispatches the command', async () => { - const webClient = createMockWebClient(); - renderWithProviders(, { - preloadedState: buildGame({ localId: 1, opponentIds: [2] }), - webClient, - }); - - fireEvent.contextMenu( - screen - .getByTestId('player-board-1') - .querySelector(`[data-testid="zone-stack-${App.ZoneName.DECK}"]`)!, - ); - fireEvent.click(await screen.findByText(/always reveal top card/i)); - - expect(webClient.request.game.changeZoneProperties).toHaveBeenCalledWith( - 1, - expect.objectContaining({ - zoneName: App.ZoneName.DECK, - alwaysRevealTopCard: true, - }), - ); - }, ORCHESTRATION_TIMEOUT_MS); - }); }); diff --git a/webclient/vite.config.ts b/webclient/vite.config.ts index 225e64adc..f4b060360 100644 --- a/webclient/vite.config.ts +++ b/webclient/vite.config.ts @@ -1,10 +1,53 @@ +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const srcPath = (...segments: string[]) => path.resolve(__dirname, 'src', ...segments); export default defineConfig({ plugins: [react()], resolve: { - tsconfigPaths: true, + alias: { + '@app/api': srcPath('api/index.ts'), + '@app/components': srcPath('components/index.ts'), + '@app/containers': srcPath('containers/index.ts'), + '@app/dialogs': srcPath('dialogs/index.ts'), + '@app/forms': srcPath('forms/index.ts'), + '@app/hooks': srcPath('hooks/index.ts'), + '@app/images': srcPath('images/index.ts'), + '@app/services': srcPath('services/index.ts'), + '@app/store': srcPath('store/index.ts'), + '@app/types': srcPath('types/index.ts'), + '@app/utils': srcPath('utils/index.ts'), + '@app/websocket/types': srcPath('websocket/types/index.ts'), + '@app/websocket': srcPath('websocket/index.ts'), + '@app/generated': srcPath('generated/index.ts'), + }, + }, + optimizeDeps: { + include: [ + '@mui/material', + '@mui/material/styles', + '@mui/icons-material', + '@emotion/react', + '@emotion/styled', + '@dnd-kit/core', + '@dnd-kit/utilities', + '@reduxjs/toolkit', + 'react-redux', + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react-router-dom', + 'i18next', + 'react-i18next', + '@testing-library/react', + '@testing-library/jest-dom/vitest', + '@bufbuild/protobuf', + ], }, publicDir: 'public', build: { @@ -21,7 +64,11 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/setupTests.ts'], include: ['src/**/*.spec.{ts,tsx}'], + exclude: ['node_modules', 'build', 'integration', 'coverage'], isolate: true, + pool: 'threads', + maxWorkers: 4, + testTimeout: 10000, coverage: { provider: 'v8', reporter: ['text', 'html'], diff --git a/webclient/vitest.integration.config.ts b/webclient/vitest.integration.config.ts index 2ceabd4d8..73ca1ec44 100644 --- a/webclient/vitest.integration.config.ts +++ b/webclient/vitest.integration.config.ts @@ -1,41 +1,21 @@ -import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config'; +import viteConfig from './vite.config'; -// Integration tests exercise the full inbound/outbound webclient pipeline -// (ProtobufService → event handlers → persistence → Redux) with only the -// browser WebSocket constructor mocked. They live in `integration/` with -// their own config so the include glob and longer testTimeout stay scoped -// to this suite; both suites run `isolate: true`. export default defineConfig({ - plugins: [react()], - resolve: { - tsconfigPaths: true, - }, + ...viteConfig, test: { - globals: true, - environment: 'jsdom', + ...viteConfig.test, setupFiles: ['./integration/src/helpers/setup.ts'], include: ['integration/src/**/*.spec.{ts,tsx}'], - // App-suite tests render the full Login container against real Dexie - // (fake-indexeddb) + real WebClient. Under CI/disk load the default - // 5s timeout is tight; 10s leaves headroom without masking real hangs. - testTimeout: 10000, + exclude: ['node_modules', 'build', 'coverage'], coverage: { - provider: 'v8', - reporter: ['text', 'html'], + ...viteConfig.test?.coverage, reportsDirectory: './coverage/integration', include: [ 'src/websocket/**/*.{ts,tsx}', 'src/store/**/*.{ts,tsx}', 'src/api/**/*.{ts,tsx}', ], - exclude: [ - 'src/generated/**', - 'src/**/*.spec.{ts,tsx}', - 'src/**/__mocks__/**', - 'src/setupTests.ts', - 'src/polyfills.ts', - ], }, }, });