improve testing speed

This commit is contained in:
seavor 2026-04-20 00:01:25 -05:00
parent 0d7336edc2
commit 5f28d43dff
7 changed files with 731 additions and 325 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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 = {},
) {

View file

@ -0,0 +1,356 @@
// M4M6 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<typeof makeCard>[];
started?: boolean;
spectator?: boolean;
judge?: boolean;
localReadyStart?: boolean;
graveCards?: ReturnType<typeof makeCard>[];
}
function buildGame({
localId,
opponentIds,
tableCards = [],
started = true,
spectator = false,
judge = false,
localReadyStart = false,
graveCards = [],
}: BuildGameOpts) {
const players: Record<number, ReturnType<typeof makePlayerEntry>> = {};
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 (M4M6)', () => {
it('Roll Die: TurnControls → RollDieDialog → rollDie dispatch', async () => {
const webClient = createMockWebClient();
renderWithProviders(<Game />, {
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(<Game />, {
// 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(<Game />, {
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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, {
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(<Game />, {
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,
}),
);
});
});

View file

@ -458,299 +458,9 @@ describe('Game container', () => {
});
});
// M4M6 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 (M4M6)', () => {
// 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;
// M4M6 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(<Game />, {
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(<Game />, {
// 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(<Game />, {
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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, { 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(<Game />, {
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(<Game />, {
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);
});
});

View file

@ -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'],

View file

@ -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',
],
},
},
});