From 367852866fd6b6d2ff7ca3adb55eb058da20e7ea Mon Sep 17 00:00:00 2001 From: seavor Date: Sun, 12 Apr 2026 11:33:55 -0500 Subject: [PATCH] implement test coverage for game layer --- .../src/store/game/__mocks__/fixtures.ts | 123 ++ webclient/src/store/game/game.actions.spec.ts | 175 +++ .../src/store/game/game.dispatch.spec.ts | 198 +++ webclient/src/store/game/game.reducer.spec.ts | 1316 +++++++++++++++++ .../src/store/game/game.selectors.spec.ts | 158 ++ .../commands/game/gameCommands.spec.ts | 200 +++ .../events/common/commonEvents.spec.ts | 20 +- .../websocket/events/game/gameEvents.spec.ts | 272 +++- .../persistence/GamePersistence.spec.ts | 182 +++ .../persistence/SessionPersistence.spec.ts | 9 +- .../websocket/services/BackendService.spec.ts | 12 + .../services/ProtobufService.spec.ts | 80 +- 12 files changed, 2721 insertions(+), 24 deletions(-) create mode 100644 webclient/src/store/game/__mocks__/fixtures.ts create mode 100644 webclient/src/store/game/game.actions.spec.ts create mode 100644 webclient/src/store/game/game.dispatch.spec.ts create mode 100644 webclient/src/store/game/game.reducer.spec.ts create mode 100644 webclient/src/store/game/game.selectors.spec.ts create mode 100644 webclient/src/websocket/commands/game/gameCommands.spec.ts diff --git a/webclient/src/store/game/__mocks__/fixtures.ts b/webclient/src/store/game/__mocks__/fixtures.ts new file mode 100644 index 000000000..ca19cdadd --- /dev/null +++ b/webclient/src/store/game/__mocks__/fixtures.ts @@ -0,0 +1,123 @@ +import { ArrowInfo, CardInfo, CounterInfo, PlayerProperties } from 'types'; +import { GameEntry, GamesState, PlayerEntry, ZoneEntry } from '../game.interfaces'; + +export function makeCard(overrides: Partial = {}): CardInfo { + return { + id: 1, + name: 'Test Card', + x: 0, + y: 0, + faceDown: false, + tapped: false, + attacking: false, + color: '', + pt: '', + annotation: '', + destroyOnZoneChange: false, + doesntUntap: false, + counterList: [], + attachPlayerId: -1, + attachZone: '', + attachCardId: -1, + providerId: '', + ...overrides, + }; +} + +export function makeCounter(overrides: Partial = {}): CounterInfo { + return { + id: 1, + name: 'Life', + counterColor: { r: 0, g: 0, b: 0, a: 255 }, + radius: 1, + count: 20, + ...overrides, + }; +} + +export function makeArrow(overrides: Partial = {}): ArrowInfo { + return { + id: 1, + startPlayerId: 1, + startZone: 'table', + startCardId: 1, + targetPlayerId: 1, + targetZone: 'table', + targetCardId: 2, + arrowColor: { r: 255, g: 0, b: 0, a: 255 }, + ...overrides, + }; +} + +export function makeZoneEntry(overrides: Partial = {}): ZoneEntry { + return { + name: 'hand', + type: 1, + withCoords: false, + cardCount: 0, + cards: [], + alwaysRevealTopCard: false, + alwaysLookAtTopCard: false, + ...overrides, + }; +} + +export function makePlayerProperties(overrides: Partial = {}): PlayerProperties { + return { + playerId: 1, + userInfo: null, + spectator: false, + conceded: false, + readyStart: false, + deckHash: '', + pingSeconds: 0, + sideboardLocked: false, + judge: false, + ...overrides, + }; +} + +export function makePlayerEntry(overrides: Partial = {}): PlayerEntry { + return { + properties: makePlayerProperties(), + deckList: '', + zones: { + hand: makeZoneEntry({ name: 'hand' }), + deck: makeZoneEntry({ name: 'deck' }), + }, + counters: {}, + arrows: {}, + ...overrides, + }; +} + +export function makeGameEntry(overrides: Partial = {}): GameEntry { + return { + gameId: 1, + roomId: 1, + description: 'Test Game', + hostId: 1, + localPlayerId: 1, + spectator: false, + judge: false, + started: false, + activePlayerId: 0, + activePhase: 0, + secondsElapsed: 0, + reversed: false, + players: { + 1: makePlayerEntry(), + }, + messages: [], + ...overrides, + }; +} + +export function makeState(overrides: Partial = {}): GamesState { + return { + games: { + 1: makeGameEntry(), + }, + ...overrides, + }; +} diff --git a/webclient/src/store/game/game.actions.spec.ts b/webclient/src/store/game/game.actions.spec.ts new file mode 100644 index 000000000..0fb2c361a --- /dev/null +++ b/webclient/src/store/game/game.actions.spec.ts @@ -0,0 +1,175 @@ +import { Actions } from './game.actions'; +import { Types } from './game.types'; +import { + makeArrow, + makeCard, + makeCounter, + makeGameEntry, + makePlayerProperties, + makeZoneEntry, +} from './__mocks__/fixtures'; + +describe('Actions', () => { + it('clearStore', () => { + expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE }); + }); + + it('gameJoined', () => { + const entry = makeGameEntry(); + expect(Actions.gameJoined(1, entry)).toEqual({ type: Types.GAME_JOINED, gameId: 1, gameEntry: entry }); + }); + + it('gameLeft', () => { + expect(Actions.gameLeft(2)).toEqual({ type: Types.GAME_LEFT, gameId: 2 }); + }); + + it('gameClosed', () => { + expect(Actions.gameClosed(3)).toEqual({ type: Types.GAME_CLOSED, gameId: 3 }); + }); + + it('gameHostChanged', () => { + expect(Actions.gameHostChanged(1, 7)).toEqual({ type: Types.GAME_HOST_CHANGED, gameId: 1, hostId: 7 }); + }); + + it('gameStateChanged', () => { + const data = { playerList: [], gameStarted: true, activePlayerId: 1, activePhase: 0, secondsElapsed: 0 }; + expect(Actions.gameStateChanged(1, data)).toEqual({ type: Types.GAME_STATE_CHANGED, gameId: 1, data }); + }); + + it('playerJoined', () => { + const props = makePlayerProperties(); + expect(Actions.playerJoined(1, props)).toEqual({ type: Types.PLAYER_JOINED, gameId: 1, playerProperties: props }); + }); + + it('playerLeft', () => { + expect(Actions.playerLeft(1, 2, 3)).toEqual({ type: Types.PLAYER_LEFT, gameId: 1, playerId: 2, reason: 3 }); + }); + + it('playerPropertiesChanged', () => { + const props = makePlayerProperties(); + expect(Actions.playerPropertiesChanged(1, 2, props)).toEqual({ + type: Types.PLAYER_PROPERTIES_CHANGED, + gameId: 1, + playerId: 2, + properties: props, + }); + }); + + it('kicked', () => { + expect(Actions.kicked(1)).toEqual({ type: Types.KICKED, gameId: 1 }); + }); + + it('cardMoved', () => { + const data = { cardId: 1 } as any; + expect(Actions.cardMoved(1, 2, data)).toEqual({ type: Types.CARD_MOVED, gameId: 1, playerId: 2, data }); + }); + + it('cardFlipped', () => { + const data = { cardId: 1 } as any; + expect(Actions.cardFlipped(1, 2, data)).toEqual({ type: Types.CARD_FLIPPED, gameId: 1, playerId: 2, data }); + }); + + it('cardDestroyed', () => { + const data = { cardId: 1 } as any; + expect(Actions.cardDestroyed(1, 2, data)).toEqual({ type: Types.CARD_DESTROYED, gameId: 1, playerId: 2, data }); + }); + + it('cardAttached', () => { + const data = { cardId: 1 } as any; + expect(Actions.cardAttached(1, 2, data)).toEqual({ type: Types.CARD_ATTACHED, gameId: 1, playerId: 2, data }); + }); + + it('tokenCreated', () => { + const data = { cardId: 1 } as any; + expect(Actions.tokenCreated(1, 2, data)).toEqual({ type: Types.TOKEN_CREATED, gameId: 1, playerId: 2, data }); + }); + + it('cardAttrChanged', () => { + const data = { cardId: 1 } as any; + expect(Actions.cardAttrChanged(1, 2, data)).toEqual({ type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: 2, data }); + }); + + it('cardCounterChanged', () => { + const data = { cardId: 1 } as any; + expect(Actions.cardCounterChanged(1, 2, data)).toEqual({ type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: 2, data }); + }); + + it('arrowCreated', () => { + const arrow = makeArrow(); + const data = { arrowInfo: arrow }; + expect(Actions.arrowCreated(1, 2, data)).toEqual({ type: Types.ARROW_CREATED, gameId: 1, playerId: 2, data }); + }); + + it('arrowDeleted', () => { + const data = { arrowId: 3 }; + expect(Actions.arrowDeleted(1, 2, data)).toEqual({ type: Types.ARROW_DELETED, gameId: 1, playerId: 2, data }); + }); + + it('counterCreated', () => { + const counter = makeCounter(); + const data = { counterInfo: counter }; + expect(Actions.counterCreated(1, 2, data)).toEqual({ type: Types.COUNTER_CREATED, gameId: 1, playerId: 2, data }); + }); + + it('counterSet', () => { + const data = { counterId: 1, value: 10 }; + expect(Actions.counterSet(1, 2, data)).toEqual({ type: Types.COUNTER_SET, gameId: 1, playerId: 2, data }); + }); + + it('counterDeleted', () => { + const data = { counterId: 1 }; + expect(Actions.counterDeleted(1, 2, data)).toEqual({ type: Types.COUNTER_DELETED, gameId: 1, playerId: 2, data }); + }); + + it('cardsDrawn', () => { + const card = makeCard(); + const data = { number: 2, cards: [card] }; + expect(Actions.cardsDrawn(1, 2, data)).toEqual({ type: Types.CARDS_DRAWN, gameId: 1, playerId: 2, data }); + }); + + it('cardsRevealed', () => { + const data = { zoneName: 'hand', cards: [] } as any; + expect(Actions.cardsRevealed(1, 2, data)).toEqual({ type: Types.CARDS_REVEALED, gameId: 1, playerId: 2, data }); + }); + + it('zoneShuffled', () => { + const data = { zoneName: 'deck', start: 0, end: 39 }; + expect(Actions.zoneShuffled(1, 2, data)).toEqual({ type: Types.ZONE_SHUFFLED, gameId: 1, playerId: 2, data }); + }); + + it('dieRolled', () => { + const data = { sides: 6, value: 4, values: [4] }; + expect(Actions.dieRolled(1, 2, data)).toEqual({ type: Types.DIE_ROLLED, gameId: 1, playerId: 2, data }); + }); + + it('activePlayerSet', () => { + expect(Actions.activePlayerSet(1, 3)).toEqual({ type: Types.ACTIVE_PLAYER_SET, gameId: 1, activePlayerId: 3 }); + }); + + it('activePhaseSet', () => { + expect(Actions.activePhaseSet(1, 2)).toEqual({ type: Types.ACTIVE_PHASE_SET, gameId: 1, phase: 2 }); + }); + + it('turnReversed', () => { + expect(Actions.turnReversed(1, true)).toEqual({ type: Types.TURN_REVERSED, gameId: 1, reversed: true }); + }); + + it('zoneDumped', () => { + const data = { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false }; + expect(Actions.zoneDumped(1, 2, data)).toEqual({ type: Types.ZONE_DUMPED, gameId: 1, playerId: 2, data }); + }); + + it('zonePropertiesChanged', () => { + const data = { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false }; + expect(Actions.zonePropertiesChanged(1, 2, data)).toEqual({ + type: Types.ZONE_PROPERTIES_CHANGED, + gameId: 1, + playerId: 2, + data, + }); + }); + + it('gameSay', () => { + expect(Actions.gameSay(1, 2, 'hello')).toEqual({ type: Types.GAME_SAY, gameId: 1, playerId: 2, message: 'hello' }); + }); +}); diff --git a/webclient/src/store/game/game.dispatch.spec.ts b/webclient/src/store/game/game.dispatch.spec.ts new file mode 100644 index 000000000..e4f9c51b9 --- /dev/null +++ b/webclient/src/store/game/game.dispatch.spec.ts @@ -0,0 +1,198 @@ +jest.mock('store/store', () => ({ store: { dispatch: jest.fn() } })); + +import { store } from 'store/store'; +import { Actions } from './game.actions'; +import { Dispatch } from './game.dispatch'; +import { + makeArrow, + makeCard, + makeCounter, + makeGameEntry, + makePlayerProperties, +} from './__mocks__/fixtures'; + +beforeEach(() => jest.clearAllMocks()); + +describe('Dispatch', () => { + it('clearStore dispatches Actions.clearStore()', () => { + Dispatch.clearStore(); + expect(store.dispatch).toHaveBeenCalledWith(Actions.clearStore()); + }); + + it('gameJoined dispatches Actions.gameJoined()', () => { + const entry = makeGameEntry(); + Dispatch.gameJoined(1, entry); + expect(store.dispatch).toHaveBeenCalledWith(Actions.gameJoined(1, entry)); + }); + + it('gameLeft dispatches Actions.gameLeft()', () => { + Dispatch.gameLeft(2); + expect(store.dispatch).toHaveBeenCalledWith(Actions.gameLeft(2)); + }); + + it('gameClosed dispatches Actions.gameClosed()', () => { + Dispatch.gameClosed(3); + expect(store.dispatch).toHaveBeenCalledWith(Actions.gameClosed(3)); + }); + + it('gameHostChanged dispatches Actions.gameHostChanged()', () => { + Dispatch.gameHostChanged(1, 7); + expect(store.dispatch).toHaveBeenCalledWith(Actions.gameHostChanged(1, 7)); + }); + + it('gameStateChanged dispatches Actions.gameStateChanged()', () => { + const data = { playerList: [], gameStarted: false, activePlayerId: 0, activePhase: 0, secondsElapsed: 0 }; + Dispatch.gameStateChanged(1, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.gameStateChanged(1, data)); + }); + + it('playerJoined dispatches Actions.playerJoined()', () => { + const props = makePlayerProperties(); + Dispatch.playerJoined(1, props); + expect(store.dispatch).toHaveBeenCalledWith(Actions.playerJoined(1, props)); + }); + + it('playerLeft dispatches Actions.playerLeft()', () => { + Dispatch.playerLeft(1, 2, 3); + expect(store.dispatch).toHaveBeenCalledWith(Actions.playerLeft(1, 2, 3)); + }); + + it('playerPropertiesChanged dispatches Actions.playerPropertiesChanged()', () => { + const props = makePlayerProperties(); + Dispatch.playerPropertiesChanged(1, 2, props); + expect(store.dispatch).toHaveBeenCalledWith(Actions.playerPropertiesChanged(1, 2, props)); + }); + + it('kicked dispatches Actions.kicked()', () => { + Dispatch.kicked(1); + expect(store.dispatch).toHaveBeenCalledWith(Actions.kicked(1)); + }); + + it('cardMoved dispatches Actions.cardMoved()', () => { + const data = { cardId: 1 } as any; + Dispatch.cardMoved(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardMoved(1, 2, data)); + }); + + it('cardFlipped dispatches Actions.cardFlipped()', () => { + const data = { cardId: 1 } as any; + Dispatch.cardFlipped(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardFlipped(1, 2, data)); + }); + + it('cardDestroyed dispatches Actions.cardDestroyed()', () => { + const data = { cardId: 1 } as any; + Dispatch.cardDestroyed(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardDestroyed(1, 2, data)); + }); + + it('cardAttached dispatches Actions.cardAttached()', () => { + const data = { cardId: 1 } as any; + Dispatch.cardAttached(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttached(1, 2, data)); + }); + + it('tokenCreated dispatches Actions.tokenCreated()', () => { + const data = { cardId: 1 } as any; + Dispatch.tokenCreated(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.tokenCreated(1, 2, data)); + }); + + it('cardAttrChanged dispatches Actions.cardAttrChanged()', () => { + const data = { cardId: 1 } as any; + Dispatch.cardAttrChanged(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttrChanged(1, 2, data)); + }); + + it('cardCounterChanged dispatches Actions.cardCounterChanged()', () => { + const data = { cardId: 1 } as any; + Dispatch.cardCounterChanged(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardCounterChanged(1, 2, data)); + }); + + it('arrowCreated dispatches Actions.arrowCreated()', () => { + const data = { arrowInfo: makeArrow() }; + Dispatch.arrowCreated(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowCreated(1, 2, data)); + }); + + it('arrowDeleted dispatches Actions.arrowDeleted()', () => { + const data = { arrowId: 3 }; + Dispatch.arrowDeleted(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowDeleted(1, 2, data)); + }); + + it('counterCreated dispatches Actions.counterCreated()', () => { + const data = { counterInfo: makeCounter() }; + Dispatch.counterCreated(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.counterCreated(1, 2, data)); + }); + + it('counterSet dispatches Actions.counterSet()', () => { + const data = { counterId: 1, value: 10 }; + Dispatch.counterSet(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.counterSet(1, 2, data)); + }); + + it('counterDeleted dispatches Actions.counterDeleted()', () => { + const data = { counterId: 1 }; + Dispatch.counterDeleted(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.counterDeleted(1, 2, data)); + }); + + it('cardsDrawn dispatches Actions.cardsDrawn()', () => { + const data = { number: 2, cards: [makeCard()] }; + Dispatch.cardsDrawn(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsDrawn(1, 2, data)); + }); + + it('cardsRevealed dispatches Actions.cardsRevealed()', () => { + const data = { zoneName: 'hand', cards: [] } as any; + Dispatch.cardsRevealed(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsRevealed(1, 2, data)); + }); + + it('zoneShuffled dispatches Actions.zoneShuffled()', () => { + const data = { zoneName: 'deck', start: 0, end: 39 }; + Dispatch.zoneShuffled(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneShuffled(1, 2, data)); + }); + + it('dieRolled dispatches Actions.dieRolled()', () => { + const data = { sides: 6, value: 4, values: [4] }; + Dispatch.dieRolled(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.dieRolled(1, 2, data)); + }); + + it('activePlayerSet dispatches Actions.activePlayerSet()', () => { + Dispatch.activePlayerSet(1, 3); + expect(store.dispatch).toHaveBeenCalledWith(Actions.activePlayerSet(1, 3)); + }); + + it('activePhaseSet dispatches Actions.activePhaseSet()', () => { + Dispatch.activePhaseSet(1, 2); + expect(store.dispatch).toHaveBeenCalledWith(Actions.activePhaseSet(1, 2)); + }); + + it('turnReversed dispatches Actions.turnReversed()', () => { + Dispatch.turnReversed(1, true); + expect(store.dispatch).toHaveBeenCalledWith(Actions.turnReversed(1, true)); + }); + + it('zoneDumped dispatches Actions.zoneDumped()', () => { + const data = { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false }; + Dispatch.zoneDumped(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneDumped(1, 2, data)); + }); + + it('zonePropertiesChanged dispatches Actions.zonePropertiesChanged()', () => { + const data = { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false }; + Dispatch.zonePropertiesChanged(1, 2, data); + expect(store.dispatch).toHaveBeenCalledWith(Actions.zonePropertiesChanged(1, 2, data)); + }); + + it('gameSay dispatches Actions.gameSay()', () => { + Dispatch.gameSay(1, 2, 'gg wp'); + expect(store.dispatch).toHaveBeenCalledWith(Actions.gameSay(1, 2, 'gg wp')); + }); +}); diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts new file mode 100644 index 000000000..92e8477cf --- /dev/null +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -0,0 +1,1316 @@ +import { CardAttribute, PlayerInfo } from 'types'; +import { gamesReducer } from './game.reducer'; +import { Types } from './game.types'; +import { + makeArrow, + makeCard, + makeCounter, + makeGameEntry, + makePlayerEntry, + makePlayerProperties, + makeState, + makeZoneEntry, +} from './__mocks__/fixtures'; + +// ── 2A: Initialisation & lifecycle ─────────────────────────────────────────── + +describe('2A: Initialisation & lifecycle', () => { + it('returns initialState ({ games: {} }) when called with undefined state', () => { + const result = gamesReducer(undefined, { type: '@@INIT' }); + expect(result).toEqual({ games: {} }); + }); + + it('CLEAR_STORE → resets to initialState', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.CLEAR_STORE }); + expect(result).toEqual({ games: {} }); + }); + + it('GAME_JOINED → inserts gameEntry keyed by gameId', () => { + const entry = makeGameEntry({ gameId: 42 }); + const result = gamesReducer({ games: {} }, { type: Types.GAME_JOINED, gameId: 42, gameEntry: entry }); + expect(result.games[42]).toBe(entry); + }); + + it('GAME_LEFT → removes game by gameId', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.GAME_LEFT, gameId: 1 }); + expect(result.games[1]).toBeUndefined(); + }); + + it('GAME_CLOSED → removes game by gameId', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.GAME_CLOSED, gameId: 1 }); + expect(result.games[1]).toBeUndefined(); + }); + + it('KICKED → removes game by gameId', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.KICKED, gameId: 1 }); + expect(result.games[1]).toBeUndefined(); + }); + + it('GAME_HOST_CHANGED → updates hostId on existing game', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.GAME_HOST_CHANGED, gameId: 1, hostId: 99 }); + expect(result.games[1].hostId).toBe(99); + expect(result).not.toBe(state); + }); +}); + +// ── 2B: Game state & player management ─────────────────────────────────────── + +describe('2B: Game state & player management', () => { + it('GAME_STATE_CHANGED with playerList → replaces players via normalizePlayers', () => { + const state = makeState(); + const card = makeCard({ id: 5 }); + const counter = makeCounter({ id: 2 }); + const arrow = makeArrow({ id: 3 }); + const playerList: PlayerInfo[] = [ + { + properties: makePlayerProperties({ playerId: 7 }), + deckList: 'some deck', + zoneList: [ + { + name: 'hand', + type: 1, + withCoords: false, + cardCount: 1, + cardList: [card], + alwaysRevealTopCard: false, + alwaysLookAtTopCard: false, + }, + ], + counterList: [counter], + arrowList: [arrow], + }, + ]; + + const result = gamesReducer(state, { + type: Types.GAME_STATE_CHANGED, + gameId: 1, + data: { playerList }, + }); + + const player = result.games[1].players[7]; + expect(player).toBeDefined(); + expect(player.zones['hand'].cards[0]).toEqual(card); + expect(player.counters[2]).toEqual(counter); + expect(player.arrows[3]).toEqual(arrow); + }); + + it('GAME_STATE_CHANGED with scalar fields → updates started, activePlayerId, activePhase, secondsElapsed', () => { + const state = makeState(); + const result = gamesReducer(state, { + type: Types.GAME_STATE_CHANGED, + gameId: 1, + data: { + gameStarted: true, + activePlayerId: 3, + activePhase: 2, + secondsElapsed: 60, + }, + }); + + expect(result.games[1].started).toBe(true); + expect(result.games[1].activePlayerId).toBe(3); + expect(result.games[1].activePhase).toBe(2); + expect(result.games[1].secondsElapsed).toBe(60); + }); + + it('PLAYER_JOINED → adds new empty PlayerEntry keyed by playerId', () => { + const state = makeState(); + const props = makePlayerProperties({ playerId: 5 }); + const result = gamesReducer(state, { type: Types.PLAYER_JOINED, gameId: 1, playerProperties: props }); + const newPlayer = result.games[1].players[5]; + expect(newPlayer).toBeDefined(); + expect(newPlayer.properties).toBe(props); + expect(newPlayer.zones).toEqual({}); + expect(newPlayer.counters).toEqual({}); + expect(newPlayer.arrows).toEqual({}); + }); + + it('PLAYER_LEFT → removes player from game.players', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.PLAYER_LEFT, gameId: 1, playerId: 1 }); + expect(result.games[1].players[1]).toBeUndefined(); + }); + + it('PLAYER_PROPERTIES_CHANGED → replaces properties on existing player', () => { + const state = makeState(); + const newProps = makePlayerProperties({ playerId: 1, conceded: true }); + const result = gamesReducer(state, { + type: Types.PLAYER_PROPERTIES_CHANGED, + gameId: 1, + playerId: 1, + properties: newProps, + }); + expect(result.games[1].players[1].properties).toBe(newProps); + }); +}); + +// ── 2C: CARD_MOVED ──────────────────────────────────────────────────────────── + +describe('2C: CARD_MOVED', () => { + function stateWithCard(cardOverrides: Parameters[0] = {}) { + const card = makeCard({ id: 10, ...cardOverrides }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + hand: makeZoneEntry({ name: 'hand', cards: [card], cardCount: 1 }), + table: makeZoneEntry({ name: 'table', cardCount: 0 }), + }, + }), + }, + }), + }, + }); + return { state, card }; + } + + it('moves card by cardId ≥ 0 from source to target zone', () => { + const { state } = stateWithCard(); + const result = gamesReducer(state, { + type: Types.CARD_MOVED, + gameId: 1, + playerId: 1, + data: { + cardId: 10, + cardName: '', + startPlayerId: 1, + startZone: 'hand', + position: -1, + targetPlayerId: 1, + targetZone: 'table', + x: 5, + y: 7, + newCardId: -1, + faceDown: false, + newCardProviderId: '', + }, + }); + + expect(result.games[1].players[1].zones['hand'].cards).toHaveLength(0); + expect(result.games[1].players[1].zones['hand'].cardCount).toBe(0); + const movedCard = result.games[1].players[1].zones['table'].cards[0]; + expect(movedCard.id).toBe(10); + expect(movedCard.x).toBe(5); + expect(movedCard.y).toBe(7); + expect(result.games[1].players[1].zones['table'].cardCount).toBe(1); + }); + + it('moves card by position index when cardId < 0', () => { + const card = makeCard({ id: 11 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [card], cardCount: 1 }), + hand: makeZoneEntry({ name: 'hand', cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.CARD_MOVED, + gameId: 1, + playerId: 1, + data: { + cardId: -1, + cardName: '', + startPlayerId: 1, + startZone: 'deck', + position: 0, + targetPlayerId: 1, + targetZone: 'hand', + x: 0, + y: 0, + newCardId: -1, + faceDown: false, + newCardProviderId: '', + }, + }); + + expect(result.games[1].players[1].zones['deck'].cards).toHaveLength(0); + expect(result.games[1].players[1].zones['hand'].cards[0].id).toBe(11); + }); + + it('hidden-zone move: cardId < 0, position out of range → decrements source cardCount, builds empty card in target', () => { + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [], cardCount: 5 }), + hand: makeZoneEntry({ name: 'hand', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.CARD_MOVED, + gameId: 1, + playerId: 1, + data: { + cardId: -1, + cardName: 'Hidden', + startPlayerId: 1, + startZone: 'deck', + position: 99, + targetPlayerId: 1, + targetZone: 'hand', + x: 0, + y: 0, + newCardId: 7, + faceDown: true, + newCardProviderId: 'prov', + }, + }); + + expect(result.games[1].players[1].zones['deck'].cardCount).toBe(4); + const movedCard = result.games[1].players[1].zones['hand'].cards[0]; + expect(movedCard.id).toBe(7); + expect(movedCard.name).toBe('Hidden'); + expect(movedCard.faceDown).toBe(true); + }); + + it('cross-player move: card leaves source player zone and enters target player zone', () => { + const card = makeCard({ id: 20 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + hand: makeZoneEntry({ name: 'hand', cards: [card], cardCount: 1 }), + }, + }), + 2: makePlayerEntry({ + properties: makePlayerProperties({ playerId: 2 }), + zones: { + hand: makeZoneEntry({ name: 'hand', cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.CARD_MOVED, + gameId: 1, + playerId: 1, + data: { + cardId: 20, + cardName: '', + startPlayerId: 1, + startZone: 'hand', + position: -1, + targetPlayerId: 2, + targetZone: 'hand', + x: 0, + y: 0, + newCardId: -1, + faceDown: false, + newCardProviderId: '', + }, + }); + + expect(result.games[1].players[1].zones['hand'].cards).toHaveLength(0); + expect(result.games[1].players[2].zones['hand'].cards[0].id).toBe(20); + }); + + it('assigns newCardId when newCardId ≥ 0', () => { + const { state } = stateWithCard(); + const result = gamesReducer(state, { + type: Types.CARD_MOVED, + gameId: 1, + playerId: 1, + data: { + cardId: 10, + cardName: '', + startPlayerId: 1, + startZone: 'hand', + position: -1, + targetPlayerId: 1, + targetZone: 'table', + x: 0, + y: 0, + newCardId: 999, + faceDown: false, + newCardProviderId: '', + }, + }); + + expect(result.games[1].players[1].zones['table'].cards[0].id).toBe(999); + }); + + it('applies newCardProviderId and cardName to moved card', () => { + const { state } = stateWithCard({ name: 'Old Name', providerId: 'old-prov' }); + const result = gamesReducer(state, { + type: Types.CARD_MOVED, + gameId: 1, + playerId: 1, + data: { + cardId: 10, + cardName: 'New Name', + startPlayerId: 1, + startZone: 'hand', + position: -1, + targetPlayerId: 1, + targetZone: 'table', + x: 0, + y: 0, + newCardId: -1, + faceDown: false, + newCardProviderId: 'new-prov', + }, + }); + + const moved = result.games[1].players[1].zones['table'].cards[0]; + expect(moved.name).toBe('New Name'); + expect(moved.providerId).toBe('new-prov'); + }); +}); + +// ── 2D: Card mutations ──────────────────────────────────────────────────────── + +describe('2D: Card mutations', () => { + function stateWithCardInZone(zoneName: string) { + const card = makeCard({ id: 5, name: 'Old', providerId: 'old', faceDown: false }); + return { + card, + state: makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + [zoneName]: makeZoneEntry({ name: zoneName, cards: [card], cardCount: 1 }), + }, + }), + }, + }), + }, + }), + }; + } + + it('CARD_FLIPPED → updates faceDown, name, and providerId', () => { + const { state } = stateWithCardInZone('hand'); + const result = gamesReducer(state, { + type: Types.CARD_FLIPPED, + gameId: 1, + playerId: 1, + data: { zoneName: 'hand', cardId: 5, cardName: 'Revealed', faceDown: true, cardProviderId: 'new-prov' }, + }); + + const card = result.games[1].players[1].zones['hand'].cards[0]; + expect(card.faceDown).toBe(true); + expect(card.name).toBe('Revealed'); + expect(card.providerId).toBe('new-prov'); + }); + + it('CARD_DESTROYED → removes card from zone and decrements cardCount', () => { + const { state } = stateWithCardInZone('hand'); + const result = gamesReducer(state, { + type: Types.CARD_DESTROYED, + gameId: 1, + playerId: 1, + data: { zoneName: 'hand', cardId: 5 }, + }); + + expect(result.games[1].players[1].zones['hand'].cards).toHaveLength(0); + expect(result.games[1].players[1].zones['hand'].cardCount).toBe(0); + }); + + it('CARD_ATTACHED → sets attachPlayerId, attachZone, attachCardId on matched card', () => { + const { state } = stateWithCardInZone('table'); + const result = gamesReducer(state, { + type: Types.CARD_ATTACHED, + gameId: 1, + playerId: 1, + data: { startZone: 'table', cardId: 5, targetPlayerId: 2, targetZone: 'table', targetCardId: 99 }, + }); + + const card = result.games[1].players[1].zones['table'].cards[0]; + expect(card.attachPlayerId).toBe(2); + expect(card.attachZone).toBe('table'); + expect(card.attachCardId).toBe(99); + }); + + it('TOKEN_CREATED → builds full CardInfo, appends to zone, increments cardCount', () => { + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + table: makeZoneEntry({ name: 'table', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.TOKEN_CREATED, + gameId: 1, + playerId: 1, + data: { + zoneName: 'table', + cardId: 77, + cardName: 'Goblin', + color: 'red', + pt: '1/1', + annotation: '', + destroyOnZoneChange: true, + x: 3, + y: 4, + cardProviderId: 'prov', + faceDown: false, + }, + }); + + const zone = result.games[1].players[1].zones['table']; + expect(zone.cardCount).toBe(1); + expect(zone.cards[0].id).toBe(77); + expect(zone.cards[0].name).toBe('Goblin'); + expect(zone.cards[0].destroyOnZoneChange).toBe(true); + }); +}); + +// ── 2E: CARD_ATTR_CHANGED ───────────────────────────────────────────────────── + +describe('2E: CARD_ATTR_CHANGED', () => { + function stateWithCard() { + const card = makeCard({ + id: 3, + tapped: false, + attacking: false, + faceDown: false, + color: '', + pt: '', + annotation: '', + doesntUntap: false, + }); + return makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + table: makeZoneEntry({ name: 'table', cards: [card], cardCount: 1 }), + }, + }), + }, + }), + }, + }); + } + + function dispatchAttr(state: ReturnType, attribute: CardAttribute, attrValue: string) { + return gamesReducer(state, { + type: Types.CARD_ATTR_CHANGED, + gameId: 1, + playerId: 1, + data: { zoneName: 'table', cardId: 3, attribute, attrValue }, + }); + } + + it('AttrTapped (1) → card.tapped = true when attrValue is "1"', () => { + const result = dispatchAttr(stateWithCard(), CardAttribute.AttrTapped, '1'); + expect(result.games[1].players[1].zones['table'].cards[0].tapped).toBe(true); + }); + + it('AttrAttacking (2) → card.attacking = true when attrValue is "1"', () => { + const result = dispatchAttr(stateWithCard(), CardAttribute.AttrAttacking, '1'); + expect(result.games[1].players[1].zones['table'].cards[0].attacking).toBe(true); + }); + + it('AttrFaceDown (3) → card.faceDown = true when attrValue is "1"', () => { + const result = dispatchAttr(stateWithCard(), CardAttribute.AttrFaceDown, '1'); + expect(result.games[1].players[1].zones['table'].cards[0].faceDown).toBe(true); + }); + + it('AttrColor (4) → card.color = attrValue', () => { + const result = dispatchAttr(stateWithCard(), CardAttribute.AttrColor, 'red'); + expect(result.games[1].players[1].zones['table'].cards[0].color).toBe('red'); + }); + + it('AttrPT (5) → card.pt = attrValue', () => { + const result = dispatchAttr(stateWithCard(), CardAttribute.AttrPT, '2/3'); + expect(result.games[1].players[1].zones['table'].cards[0].pt).toBe('2/3'); + }); + + it('AttrAnnotation (6) → card.annotation = attrValue', () => { + const result = dispatchAttr(stateWithCard(), CardAttribute.AttrAnnotation, 'enchanted'); + expect(result.games[1].players[1].zones['table'].cards[0].annotation).toBe('enchanted'); + }); + + it('AttrDoesntUntap (7) → card.doesntUntap = true when attrValue is "1"', () => { + const result = dispatchAttr(stateWithCard(), CardAttribute.AttrDoesntUntap, '1'); + expect(result.games[1].players[1].zones['table'].cards[0].doesntUntap).toBe(true); + }); +}); + +// ── 2F: CARD_COUNTER_CHANGED ───────────────────────────────────────────────── + +describe('2F: CARD_COUNTER_CHANGED', () => { + function stateWithCard(existingCounters: any[] = []) { + const card = makeCard({ id: 4, counterList: existingCounters }); + return makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + table: makeZoneEntry({ name: 'table', cards: [card], cardCount: 1 }), + }, + }), + }, + }), + }, + }); + } + + it('adds new counter to counterList when counterId not present and counterValue > 0', () => { + const state = stateWithCard([]); + const result = gamesReducer(state, { + type: Types.CARD_COUNTER_CHANGED, + gameId: 1, + playerId: 1, + data: { zoneName: 'table', cardId: 4, counterId: 1, counterValue: 3 }, + }); + expect(result.games[1].players[1].zones['table'].cards[0].counterList).toEqual([{ id: 1, value: 3 }]); + }); + + it('updates existing counter value when counterId matches', () => { + const state = stateWithCard([{ id: 1, value: 3 }]); + const result = gamesReducer(state, { + type: Types.CARD_COUNTER_CHANGED, + gameId: 1, + playerId: 1, + data: { zoneName: 'table', cardId: 4, counterId: 1, counterValue: 7 }, + }); + expect(result.games[1].players[1].zones['table'].cards[0].counterList).toEqual([{ id: 1, value: 7 }]); + }); + + it('removes counter from counterList when counterValue ≤ 0', () => { + const state = stateWithCard([{ id: 1, value: 3 }]); + const result = gamesReducer(state, { + type: Types.CARD_COUNTER_CHANGED, + gameId: 1, + playerId: 1, + data: { zoneName: 'table', cardId: 4, counterId: 1, counterValue: 0 }, + }); + expect(result.games[1].players[1].zones['table'].cards[0].counterList).toEqual([]); + }); +}); + +// ── 2G: Arrows ──────────────────────────────────────────────────────────────── + +describe('2G: Arrows', () => { + it('ARROW_CREATED → inserts arrowInfo into player.arrows keyed by id', () => { + const state = makeState(); + const arrow = makeArrow({ id: 9 }); + const result = gamesReducer(state, { + type: Types.ARROW_CREATED, + gameId: 1, + playerId: 1, + data: { arrowInfo: arrow }, + }); + expect(result.games[1].players[1].arrows[9]).toEqual(arrow); + }); + + it('ARROW_DELETED → removes arrow from player.arrows by arrowId', () => { + const arrow = makeArrow({ id: 9 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ arrows: { 9: arrow } }), + }, + }), + }, + }); + const result = gamesReducer(state, { + type: Types.ARROW_DELETED, + gameId: 1, + playerId: 1, + data: { arrowId: 9 }, + }); + expect(result.games[1].players[1].arrows[9]).toBeUndefined(); + }); +}); + +// ── 2H: Player counters ─────────────────────────────────────────────────────── + +describe('2H: Player counters', () => { + it('COUNTER_CREATED → inserts counterInfo into player.counters keyed by id', () => { + const state = makeState(); + const counter = makeCounter({ id: 5, name: 'Poison' }); + const result = gamesReducer(state, { + type: Types.COUNTER_CREATED, + gameId: 1, + playerId: 1, + data: { counterInfo: counter }, + }); + expect(result.games[1].players[1].counters[5]).toEqual(counter); + }); + + it('COUNTER_SET → updates counter.count to new value', () => { + const counter = makeCounter({ id: 5, count: 20 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ counters: { 5: counter } }), + }, + }), + }, + }); + const result = gamesReducer(state, { + type: Types.COUNTER_SET, + gameId: 1, + playerId: 1, + data: { counterId: 5, value: 14 }, + }); + expect(result.games[1].players[1].counters[5].count).toBe(14); + }); + + it('COUNTER_DELETED → removes counter from player.counters by counterId', () => { + const counter = makeCounter({ id: 5 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ counters: { 5: counter } }), + }, + }), + }, + }); + const result = gamesReducer(state, { + type: Types.COUNTER_DELETED, + gameId: 1, + playerId: 1, + data: { counterId: 5 }, + }); + expect(result.games[1].players[1].counters[5]).toBeUndefined(); + }); +}); + +// ── 2I: Zone operations ─────────────────────────────────────────────────────── + +describe('2I: Zone operations', () => { + it('CARDS_DRAWN → decrements deck.cardCount, appends cards to hand, increments hand.cardCount', () => { + const drawnCard = makeCard({ id: 9 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cardCount: 40 }), + hand: makeZoneEntry({ name: 'hand', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.CARDS_DRAWN, + gameId: 1, + playerId: 1, + data: { number: 2, cards: [drawnCard] }, + }); + + expect(result.games[1].players[1].zones['deck'].cardCount).toBe(38); + expect(result.games[1].players[1].zones['hand'].cards).toContainEqual(drawnCard); + expect(result.games[1].players[1].zones['hand'].cardCount).toBe(2); + }); + + it('CARDS_DRAWN → works when no deck zone exists (only updates hand)', () => { + const drawnCard = makeCard({ id: 9 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + hand: makeZoneEntry({ name: 'hand', cards: [], cardCount: 0 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.CARDS_DRAWN, + gameId: 1, + playerId: 1, + data: { number: 1, cards: [drawnCard] }, + }); + + expect(result.games[1].players[1].zones['hand'].cardCount).toBe(1); + expect(result.games[1].players[1].zones['hand'].cards).toContainEqual(drawnCard); + }); + + it('CARDS_REVEALED (update path) → merges revealed cards into existing zone cards', () => { + const existing = makeCard({ id: 2, name: 'Old Name' }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [existing], cardCount: 1 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.CARDS_REVEALED, + gameId: 1, + playerId: 1, + data: { zoneName: 'deck', cards: [{ ...existing, name: 'Revealed Name' }] }, + }); + + expect(result.games[1].players[1].zones['deck'].cards[0].name).toBe('Revealed Name'); + expect(result.games[1].players[1].zones['deck'].cards).toHaveLength(1); + }); + + it('CARDS_REVEALED (append path) → appends new cards whose ids are not already in the zone', () => { + const existing = makeCard({ id: 1 }); + const newCard = makeCard({ id: 2 }); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { + deck: makeZoneEntry({ name: 'deck', cards: [existing], cardCount: 1 }), + }, + }), + }, + }), + }, + }); + + const result = gamesReducer(state, { + type: Types.CARDS_REVEALED, + gameId: 1, + playerId: 1, + data: { zoneName: 'deck', cards: [newCard] }, + }); + + expect(result.games[1].players[1].zones['deck'].cards).toHaveLength(2); + expect(result.games[1].players[1].zones['deck'].cards[1]).toEqual(newCard); + }); + + it('ZONE_PROPERTIES_CHANGED → sets alwaysRevealTopCard and alwaysLookAtTopCard', () => { + const state = makeState(); + const result = gamesReducer(state, { + type: Types.ZONE_PROPERTIES_CHANGED, + gameId: 1, + playerId: 1, + data: { zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true }, + }); + + const zone = result.games[1].players[1].zones['hand']; + expect(zone.alwaysRevealTopCard).toBe(true); + expect(zone.alwaysLookAtTopCard).toBe(true); + }); +}); + +// ── 2J: Turn / phase / chat ─────────────────────────────────────────────────── + +describe('2J: Turn, phase, and chat', () => { + it('ACTIVE_PLAYER_SET → sets game.activePlayerId', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.ACTIVE_PLAYER_SET, gameId: 1, activePlayerId: 3 }); + expect(result.games[1].activePlayerId).toBe(3); + }); + + it('ACTIVE_PHASE_SET → sets game.activePhase', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.ACTIVE_PHASE_SET, gameId: 1, phase: 5 }); + expect(result.games[1].activePhase).toBe(5); + }); + + it('TURN_REVERSED → sets game.reversed', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.TURN_REVERSED, gameId: 1, reversed: true }); + expect(result.games[1].reversed).toBe(true); + }); + + it('GAME_SAY → appends message with mocked Date.now() as timeReceived', () => { + const state = makeState(); + jest.spyOn(Date, 'now').mockReturnValue(123456789); + const result = gamesReducer(state, { + type: Types.GAME_SAY, + gameId: 1, + playerId: 2, + message: 'gg', + }); + jest.restoreAllMocks(); + + expect(result.games[1].messages).toHaveLength(1); + expect(result.games[1].messages[0]).toEqual({ playerId: 2, message: 'gg', timeReceived: 123456789 }); + }); +}); + +// ── 2K: No-op / passthrough actions ────────────────────────────────────────── + +describe('2K: No-op / passthrough actions', () => { + it('ZONE_SHUFFLED → returns state unchanged (identity)', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.ZONE_SHUFFLED, gameId: 1, playerId: 1 }); + expect(result).toBe(state); + }); + + it('ZONE_DUMPED → returns state unchanged (identity)', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.ZONE_DUMPED, gameId: 1, playerId: 1 }); + expect(result).toBe(state); + }); + + it('DIE_ROLLED → returns state unchanged (identity)', () => { + const state = makeState(); + const result = gamesReducer(state, { type: Types.DIE_ROLLED, gameId: 1, playerId: 1 }); + expect(result).toBe(state); + }); + + it('unknown action type → returns state unchanged (identity)', () => { + const state = makeState(); + const result = gamesReducer(state, { type: 'UNKNOWN_ACTION_COMPLETELY' }); + expect(result).toBe(state); + }); +}); + +// ── 2L: Null-guard / missing entity early-returns ───────────────────────────── +// Each test dispatches an action with a non-existent gameId (999) or playerId/zone +// to exercise the `if (!game) return state` / `if (!player) return state` guards. + +describe('2L: Null-guard / missing entity early-returns', () => { + const UNKNOWN_GAME = 999; + const UNKNOWN_PLAYER = 999; + + it('updateGame guard: GAME_HOST_CHANGED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.GAME_HOST_CHANGED, gameId: UNKNOWN_GAME, hostId: 1 })).toBe(state); + }); + + it('GAME_STATE_CHANGED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.GAME_STATE_CHANGED, gameId: UNKNOWN_GAME, data: {} })).toBe(state); + }); + + it('PLAYER_JOINED with unknown gameId → state unchanged', () => { + const state = makeState(); + const props = makePlayerProperties({ playerId: 5 }); + expect(gamesReducer(state, { type: Types.PLAYER_JOINED, gameId: UNKNOWN_GAME, playerProperties: props })).toBe(state); + }); + + it('PLAYER_LEFT with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.PLAYER_LEFT, gameId: UNKNOWN_GAME, playerId: 1 })).toBe(state); + }); + + it('updatePlayer guard: PLAYER_PROPERTIES_CHANGED with unknown gameId → state unchanged', () => { + const state = makeState(); + const props = makePlayerProperties({ playerId: 1 }); + expect(gamesReducer(state, { + type: Types.PLAYER_PROPERTIES_CHANGED, gameId: UNKNOWN_GAME, playerId: 1, properties: props, + })).toBe(state); + }); + + it('updatePlayer guard: PLAYER_PROPERTIES_CHANGED with unknown playerId → state unchanged', () => { + const state = makeState(); + const props = makePlayerProperties({ playerId: UNKNOWN_PLAYER }); + expect(gamesReducer(state, { + type: Types.PLAYER_PROPERTIES_CHANGED, gameId: 1, playerId: UNKNOWN_PLAYER, properties: props, + })).toBe(state); + }); + + it('CARD_MOVED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_MOVED, gameId: UNKNOWN_GAME, playerId: 1, + data: { + cardId: 1, cardName: '', startPlayerId: 1, startZone: 'hand', position: -1, + targetPlayerId: 1, targetZone: 'hand', x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '', + }, + })).toBe(state); + }); + + it('CARD_MOVED with unknown sourcePlayer → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_MOVED, gameId: 1, playerId: 1, + data: { + cardId: 1, cardName: '', startPlayerId: UNKNOWN_PLAYER, startZone: 'hand', position: -1, + targetPlayerId: 1, targetZone: 'hand', x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '', + }, + })).toBe(state); + }); + + it('CARD_MOVED with unknown sourceZone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_MOVED, gameId: 1, playerId: 1, + data: { + cardId: 1, cardName: '', startPlayerId: 1, startZone: 'nonexistent', position: -1, + targetPlayerId: 1, targetZone: 'hand', x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '', + }, + })).toBe(state); + }); + + it('CARD_FLIPPED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_FLIPPED, gameId: UNKNOWN_GAME, playerId: 1, + data: { zoneName: 'hand', cardId: 1, cardName: '', faceDown: false, cardProviderId: '' }, + })).toBe(state); + }); + + it('CARD_FLIPPED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_FLIPPED, gameId: 1, playerId: UNKNOWN_PLAYER, + data: { zoneName: 'hand', cardId: 1, cardName: '', faceDown: false, cardProviderId: '' }, + })).toBe(state); + }); + + it('CARD_FLIPPED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_FLIPPED, gameId: 1, playerId: 1, + data: { zoneName: 'nonexistent', cardId: 1, cardName: '', faceDown: false, cardProviderId: '' }, + })).toBe(state); + }); + + it('CARD_FLIPPED with unknown cardId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_FLIPPED, gameId: 1, playerId: 1, + data: { zoneName: 'hand', cardId: 9999, cardName: '', faceDown: false, cardProviderId: '' }, + })).toBe(state); + }); + + it('CARD_DESTROYED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_DESTROYED, gameId: UNKNOWN_GAME, playerId: 1, + data: { zoneName: 'hand', cardId: 1 }, + })).toBe(state); + }); + + it('CARD_DESTROYED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_DESTROYED, gameId: 1, playerId: UNKNOWN_PLAYER, + data: { zoneName: 'hand', cardId: 1 }, + })).toBe(state); + }); + + it('CARD_DESTROYED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_DESTROYED, gameId: 1, playerId: 1, + data: { zoneName: 'nonexistent', cardId: 1 }, + })).toBe(state); + }); + + it('CARD_ATTACHED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTACHED, gameId: UNKNOWN_GAME, playerId: 1, + data: { startZone: 'hand', cardId: 1, targetPlayerId: 1, targetZone: 'hand', targetCardId: 1 }, + })).toBe(state); + }); + + it('CARD_ATTACHED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTACHED, gameId: 1, playerId: UNKNOWN_PLAYER, + data: { startZone: 'hand', cardId: 1, targetPlayerId: 1, targetZone: 'hand', targetCardId: 1 }, + })).toBe(state); + }); + + it('CARD_ATTACHED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTACHED, gameId: 1, playerId: 1, + data: { startZone: 'nonexistent', cardId: 1, targetPlayerId: 1, targetZone: 'hand', targetCardId: 1 }, + })).toBe(state); + }); + + it('CARD_ATTACHED with unknown cardId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTACHED, gameId: 1, playerId: 1, + data: { startZone: 'hand', cardId: 9999, targetPlayerId: 1, targetZone: 'hand', targetCardId: 1 }, + })).toBe(state); + }); + + it('TOKEN_CREATED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.TOKEN_CREATED, gameId: UNKNOWN_GAME, playerId: 1, + data: { + zoneName: 'hand', cardId: 1, cardName: 'T', color: '', pt: '', annotation: '', + destroyOnZoneChange: false, x: 0, y: 0, cardProviderId: '', faceDown: false, + }, + })).toBe(state); + }); + + it('TOKEN_CREATED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.TOKEN_CREATED, gameId: 1, playerId: UNKNOWN_PLAYER, + data: { + zoneName: 'hand', cardId: 1, cardName: 'T', color: '', pt: '', annotation: '', + destroyOnZoneChange: false, x: 0, y: 0, cardProviderId: '', faceDown: false, + }, + })).toBe(state); + }); + + it('TOKEN_CREATED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.TOKEN_CREATED, gameId: 1, playerId: 1, + data: { + zoneName: 'nonexistent', cardId: 1, cardName: 'T', color: '', pt: '', annotation: '', + destroyOnZoneChange: false, x: 0, y: 0, cardProviderId: '', faceDown: false, + }, + })).toBe(state); + }); + + it('CARD_ATTR_CHANGED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTR_CHANGED, gameId: UNKNOWN_GAME, playerId: 1, + data: { zoneName: 'hand', cardId: 1, attribute: 1, attrValue: '1' }, + })).toBe(state); + }); + + it('CARD_ATTR_CHANGED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: UNKNOWN_PLAYER, + data: { zoneName: 'hand', cardId: 1, attribute: 1, attrValue: '1' }, + })).toBe(state); + }); + + it('CARD_ATTR_CHANGED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: 1, + data: { zoneName: 'nonexistent', cardId: 1, attribute: 1, attrValue: '1' }, + })).toBe(state); + }); + + it('CARD_ATTR_CHANGED with unknown cardId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: 1, + data: { zoneName: 'hand', cardId: 9999, attribute: 1, attrValue: '1' }, + })).toBe(state); + }); + + it('CARD_COUNTER_CHANGED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_COUNTER_CHANGED, gameId: UNKNOWN_GAME, playerId: 1, + data: { zoneName: 'hand', cardId: 1, counterId: 1, counterValue: 1 }, + })).toBe(state); + }); + + it('CARD_COUNTER_CHANGED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: UNKNOWN_PLAYER, + data: { zoneName: 'hand', cardId: 1, counterId: 1, counterValue: 1 }, + })).toBe(state); + }); + + it('CARD_COUNTER_CHANGED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: 1, + data: { zoneName: 'nonexistent', cardId: 1, counterId: 1, counterValue: 1 }, + })).toBe(state); + }); + + it('CARD_COUNTER_CHANGED with unknown cardId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: 1, + data: { zoneName: 'hand', cardId: 9999, counterId: 1, counterValue: 1 }, + })).toBe(state); + }); + + it('ARROW_CREATED with unknown gameId → state unchanged', () => { + const state = makeState(); + const arrow = makeArrow({ id: 1 }); + expect(gamesReducer(state, { type: Types.ARROW_CREATED, gameId: UNKNOWN_GAME, playerId: 1, data: { arrowInfo: arrow } })).toBe(state); + }); + + it('ARROW_CREATED with unknown playerId → state unchanged', () => { + const state = makeState(); + const arrow = makeArrow({ id: 1 }); + expect(gamesReducer(state, { type: Types.ARROW_CREATED, gameId: 1, playerId: UNKNOWN_PLAYER, data: { arrowInfo: arrow } })).toBe(state); + }); + + it('ARROW_DELETED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.ARROW_DELETED, gameId: UNKNOWN_GAME, playerId: 1, data: { arrowId: 1 } })).toBe(state); + }); + + it('ARROW_DELETED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.ARROW_DELETED, gameId: 1, playerId: UNKNOWN_PLAYER, data: { arrowId: 1 } })).toBe(state); + }); + + it('COUNTER_CREATED with unknown gameId → state unchanged', () => { + const state = makeState(); + const counter = makeCounter({ id: 1 }); + expect(gamesReducer(state, { + type: Types.COUNTER_CREATED, gameId: UNKNOWN_GAME, playerId: 1, data: { counterInfo: counter }, + })).toBe(state); + }); + + it('COUNTER_CREATED with unknown playerId → state unchanged', () => { + const state = makeState(); + const counter = makeCounter({ id: 1 }); + expect(gamesReducer(state, { + type: Types.COUNTER_CREATED, gameId: 1, playerId: UNKNOWN_PLAYER, data: { counterInfo: counter }, + })).toBe(state); + }); + + it('COUNTER_SET with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.COUNTER_SET, gameId: UNKNOWN_GAME, playerId: 1, data: { counterId: 1, value: 5 }, + })).toBe(state); + }); + + it('COUNTER_SET with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.COUNTER_SET, gameId: 1, playerId: UNKNOWN_PLAYER, data: { counterId: 1, value: 5 }, + })).toBe(state); + }); + + it('COUNTER_SET with unknown counterId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.COUNTER_SET, gameId: 1, playerId: 1, data: { counterId: 9999, value: 5 } })).toBe(state); + }); + + it('COUNTER_DELETED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.COUNTER_DELETED, gameId: UNKNOWN_GAME, playerId: 1, data: { counterId: 1 } })).toBe(state); + }); + + it('COUNTER_DELETED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.COUNTER_DELETED, gameId: 1, playerId: UNKNOWN_PLAYER, data: { counterId: 1 } })).toBe(state); + }); + + it('CARDS_DRAWN with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARDS_DRAWN, gameId: UNKNOWN_GAME, playerId: 1, data: { number: 1, cards: [] }, + })).toBe(state); + }); + + it('CARDS_DRAWN with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARDS_DRAWN, gameId: 1, playerId: UNKNOWN_PLAYER, data: { number: 1, cards: [] } + })).toBe(state); + }); + + it('CARDS_DRAWN with no hand zone → state unchanged', () => { + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ zones: { deck: makeZoneEntry({ name: 'deck', cardCount: 10 }) } }), + }, + }), + }, + }); + expect(gamesReducer(state, { type: Types.CARDS_DRAWN, gameId: 1, playerId: 1, data: { number: 1, cards: [] } })).toBe(state); + }); + + it('CARDS_REVEALED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARDS_REVEALED, gameId: UNKNOWN_GAME, playerId: 1, data: { zoneName: 'hand', cards: [] }, + })).toBe(state); + }); + + it('CARDS_REVEALED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARDS_REVEALED, gameId: 1, playerId: UNKNOWN_PLAYER, data: { zoneName: 'hand', cards: [] }, + })).toBe(state); + }); + + it('CARDS_REVEALED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.CARDS_REVEALED, gameId: 1, playerId: 1, data: { zoneName: 'nonexistent', cards: [] }, + })).toBe(state); + }); + + it('updateZone guard: ZONE_PROPERTIES_CHANGED with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.ZONE_PROPERTIES_CHANGED, gameId: UNKNOWN_GAME, playerId: 1, + data: { zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true }, + })).toBe(state); + }); + + it('updateZone guard: ZONE_PROPERTIES_CHANGED with unknown playerId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.ZONE_PROPERTIES_CHANGED, gameId: 1, playerId: UNKNOWN_PLAYER, + data: { zoneName: 'hand', alwaysRevealTopCard: true, alwaysLookAtTopCard: true }, + })).toBe(state); + }); + + it('updateZone guard: ZONE_PROPERTIES_CHANGED with unknown zone → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { + type: Types.ZONE_PROPERTIES_CHANGED, gameId: 1, playerId: 1, + data: { zoneName: 'nonexistent', alwaysRevealTopCard: true, alwaysLookAtTopCard: true }, + })).toBe(state); + }); + + it('GAME_SAY with unknown gameId → state unchanged', () => { + const state = makeState(); + expect(gamesReducer(state, { type: Types.GAME_SAY, gameId: UNKNOWN_GAME, playerId: 1, message: 'hi' })).toBe(state); + }); +}); diff --git a/webclient/src/store/game/game.selectors.spec.ts b/webclient/src/store/game/game.selectors.spec.ts new file mode 100644 index 000000000..de38ef580 --- /dev/null +++ b/webclient/src/store/game/game.selectors.spec.ts @@ -0,0 +1,158 @@ +import { Selectors } from './game.selectors'; +import { + makeGameEntry, makePlayerEntry, makePlayerProperties, makeState, + makeZoneEntry, makeCard, makeCounter, makeArrow, +} from './__mocks__/fixtures'; +import { GamesState } from './game.interfaces'; + +function rootState(games: GamesState) { + return { games }; +} + +describe('Selectors', () => { + it('getGames → returns the games map', () => { + const state = makeState(); + expect(Selectors.getGames(rootState(state))).toBe(state.games); + }); + + it('getGame → returns the game entry for a given gameId', () => { + const state = makeState(); + expect(Selectors.getGame(rootState(state), 1)).toBe(state.games[1]); + }); + + it('getGame → returns undefined for unknown gameId', () => { + const state = makeState(); + expect(Selectors.getGame(rootState(state), 999)).toBeUndefined(); + }); + + it('getPlayers → returns players map for a game', () => { + const state = makeState(); + expect(Selectors.getPlayers(rootState(state), 1)).toBe(state.games[1].players); + }); + + it('getPlayers → returns undefined for unknown gameId', () => { + const state = makeState(); + expect(Selectors.getPlayers(rootState(state), 999)).toBeUndefined(); + }); + + it('getPlayer → returns a specific player', () => { + const state = makeState(); + expect(Selectors.getPlayer(rootState(state), 1, 1)).toBe(state.games[1].players[1]); + }); + + it('getLocalPlayerId → returns localPlayerId from game', () => { + const state = makeState({ games: { 1: makeGameEntry({ localPlayerId: 42 }) } }); + expect(Selectors.getLocalPlayerId(rootState(state), 1)).toBe(42); + }); + + it('getLocalPlayer → returns the player matching localPlayerId', () => { + const state = makeState({ games: { 1: makeGameEntry({ localPlayerId: 1 }) } }); + const result = Selectors.getLocalPlayer(rootState(state), 1); + expect(result).toBe(state.games[1].players[1]); + }); + + it('getLocalPlayer → returns undefined when game is not found', () => { + const state = makeState(); + expect(Selectors.getLocalPlayer(rootState(state), 999)).toBeUndefined(); + }); + + it('getZones → returns zones map for a player', () => { + const state = makeState(); + expect(Selectors.getZones(rootState(state), 1, 1)).toBe(state.games[1].players[1].zones); + }); + + it('getZone → returns a specific zone', () => { + const state = makeState(); + expect(Selectors.getZone(rootState(state), 1, 1, 'hand')).toBe(state.games[1].players[1].zones['hand']); + }); + + it('getCards → returns cards array for a zone', () => { + const card = makeCard(); + const state = makeState({ + games: { + 1: makeGameEntry({ + players: { + 1: makePlayerEntry({ + zones: { hand: makeZoneEntry({ name: 'hand', cards: [card] }) }, + }), + }, + }), + }, + }); + expect(Selectors.getCards(rootState(state), 1, 1, 'hand')).toEqual([card]); + }); + + it('getCards → returns [] when zone not found', () => { + const state = makeState(); + expect(Selectors.getCards(rootState(state), 1, 1, 'nonexistent')).toEqual([]); + }); + + it('getCounters → returns counters map for a player', () => { + const counter = makeCounter({ id: 2 }); + const state = makeState({ + games: { 1: makeGameEntry({ players: { 1: makePlayerEntry({ counters: { 2: counter } }) } }) }, + }); + expect(Selectors.getCounters(rootState(state), 1, 1)).toEqual({ 2: counter }); + }); + + it('getArrows → returns arrows map for a player', () => { + const arrow = makeArrow({ id: 3 }); + const state = makeState({ + games: { 1: makeGameEntry({ players: { 1: makePlayerEntry({ arrows: { 3: arrow } }) } }) }, + }); + expect(Selectors.getArrows(rootState(state), 1, 1)).toEqual({ 3: arrow }); + }); + + it('getActivePlayerId → returns activePlayerId from game', () => { + const state = makeState({ games: { 1: makeGameEntry({ activePlayerId: 7 }) } }); + expect(Selectors.getActivePlayerId(rootState(state), 1)).toBe(7); + }); + + it('getActivePhase → returns activePhase from game', () => { + const state = makeState({ games: { 1: makeGameEntry({ activePhase: 3 }) } }); + expect(Selectors.getActivePhase(rootState(state), 1)).toBe(3); + }); + + it('isStarted → returns true when game is started', () => { + const state = makeState({ games: { 1: makeGameEntry({ started: true }) } }); + expect(Selectors.isStarted(rootState(state), 1)).toBe(true); + }); + + it('isStarted → returns false when game not found', () => { + const state = makeState(); + expect(Selectors.isStarted(rootState(state), 999)).toBe(false); + }); + + it('isSpectator → returns spectator flag from game', () => { + const state = makeState({ games: { 1: makeGameEntry({ spectator: true }) } }); + expect(Selectors.isSpectator(rootState(state), 1)).toBe(true); + }); + + it('isReversed → returns reversed flag from game', () => { + const state = makeState({ games: { 1: makeGameEntry({ reversed: true }) } }); + expect(Selectors.isReversed(rootState(state), 1)).toBe(true); + }); + + it('getMessages → returns messages array from game', () => { + const messages = [{ playerId: 1, message: 'hi', timeReceived: 100 }]; + const state = makeState({ games: { 1: makeGameEntry({ messages }) } }); + expect(Selectors.getMessages(rootState(state), 1)).toBe(messages); + }); + + it('getMessages → returns [] when game not found', () => { + const state = makeState(); + expect(Selectors.getMessages(rootState(state), 999)).toEqual([]); + }); + + it('getActiveGameIds → returns numeric array of gameIds', () => { + const state = makeState({ + games: { + 1: makeGameEntry({ gameId: 1 }), + 2: makeGameEntry({ gameId: 2 }), + }, + }); + const ids = Selectors.getActiveGameIds(rootState(state)); + expect(ids).toEqual(expect.arrayContaining([1, 2])); + expect(ids).toHaveLength(2); + }); +}); diff --git a/webclient/src/websocket/commands/game/gameCommands.spec.ts b/webclient/src/websocket/commands/game/gameCommands.spec.ts new file mode 100644 index 000000000..6d802136d --- /dev/null +++ b/webclient/src/websocket/commands/game/gameCommands.spec.ts @@ -0,0 +1,200 @@ +import { BackendService } from '../../services/BackendService'; +import { attachCard } from './attachCard'; +import { changeZoneProperties } from './changeZoneProperties'; +import { concede } from './concede'; +import { createArrow } from './createArrow'; +import { createCounter } from './createCounter'; +import { createToken } from './createToken'; +import { deckSelect } from './deckSelect'; +import { delCounter } from './delCounter'; +import { deleteArrow } from './deleteArrow'; +import { drawCards } from './drawCards'; +import { dumpZone } from './dumpZone'; +import { flipCard } from './flipCard'; +import { gameSay } from './gameSay'; +import { incCardCounter } from './incCardCounter'; +import { incCounter } from './incCounter'; +import { kickFromGame } from './kickFromGame'; +import { leaveGame } from './leaveGame'; +import { moveCard } from './moveCard'; +import { mulligan } from './mulligan'; +import { nextTurn } from './nextTurn'; +import { readyStart } from './readyStart'; +import { revealCards } from './revealCards'; +import { reverseTurn } from './reverseTurn'; +import { setActivePhase } from './setActivePhase'; +import { setCardAttr } from './setCardAttr'; +import { setCardCounter } from './setCardCounter'; +import { setCounter } from './setCounter'; +import { setSideboardLock } from './setSideboardLock'; +import { setSideboardPlan } from './setSideboardPlan'; +import { shuffle } from './shuffle'; +import { undoDraw } from './undoDraw'; + +jest.mock('../../services/BackendService', () => ({ + BackendService: { sendGameCommand: jest.fn() }, +})); + +const gameId = 1; +const params = {} as any; + +beforeEach(() => { + (BackendService.sendGameCommand as jest.Mock).mockClear(); +}); + +describe('Game commands — delegate to BackendService.sendGameCommand', () => { + it('attachCard sends Command_AttachCard', () => { + attachCard(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_AttachCard', params); + }); + + it('changeZoneProperties sends Command_ChangeZoneProperties', () => { + changeZoneProperties(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_ChangeZoneProperties', params); + }); + + it('concede sends Command_Concede with empty object', () => { + concede(gameId); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Concede', {}); + }); + + it('createArrow sends Command_CreateArrow', () => { + createArrow(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_CreateArrow', params); + }); + + it('createCounter sends Command_CreateCounter', () => { + createCounter(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_CreateCounter', params); + }); + + it('createToken sends Command_CreateToken', () => { + createToken(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_CreateToken', params); + }); + + it('deckSelect sends Command_DeckSelect', () => { + deckSelect(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DeckSelect', params); + }); + + it('delCounter sends Command_DelCounter', () => { + delCounter(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DelCounter', params); + }); + + it('deleteArrow sends Command_DeleteArrow', () => { + deleteArrow(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DeleteArrow', params); + }); + + it('drawCards sends Command_DrawCards', () => { + drawCards(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DrawCards', params); + }); + + it('dumpZone sends Command_DumpZone', () => { + dumpZone(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DumpZone', params); + }); + + it('flipCard sends Command_FlipCard', () => { + flipCard(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_FlipCard', params); + }); + + it('gameSay sends Command_GameSay', () => { + gameSay(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_GameSay', params); + }); + + it('incCardCounter sends Command_IncCardCounter', () => { + incCardCounter(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_IncCardCounter', params); + }); + + it('incCounter sends Command_IncCounter', () => { + incCounter(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_IncCounter', params); + }); + + it('kickFromGame sends Command_KickFromGame', () => { + kickFromGame(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_KickFromGame', params); + }); + + it('leaveGame sends Command_LeaveGame with empty object', () => { + leaveGame(gameId); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_LeaveGame', {}); + }); + + it('moveCard sends Command_MoveCard', () => { + moveCard(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_MoveCard', params); + }); + + it('mulligan sends Command_Mulligan', () => { + mulligan(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Mulligan', params); + }); + + it('nextTurn sends Command_NextTurn with empty object', () => { + nextTurn(gameId); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_NextTurn', {}); + }); + + it('readyStart sends Command_ReadyStart', () => { + readyStart(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_ReadyStart', params); + }); + + it('revealCards sends Command_RevealCards', () => { + revealCards(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_RevealCards', params); + }); + + it('reverseTurn sends Command_ReverseTurn with empty object', () => { + reverseTurn(gameId); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_ReverseTurn', {}); + }); + + it('setActivePhase sends Command_SetActivePhase', () => { + setActivePhase(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetActivePhase', params); + }); + + it('setCardAttr sends Command_SetCardAttr', () => { + setCardAttr(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetCardAttr', params); + }); + + it('setCardCounter sends Command_SetCardCounter', () => { + setCardCounter(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetCardCounter', params); + }); + + it('setCounter sends Command_SetCounter', () => { + setCounter(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetCounter', params); + }); + + it('setSideboardLock sends Command_SetSideboardLock', () => { + setSideboardLock(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetSideboardLock', params); + }); + + it('setSideboardPlan sends Command_SetSideboardPlan', () => { + setSideboardPlan(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetSideboardPlan', params); + }); + + it('shuffle sends Command_Shuffle', () => { + shuffle(gameId, params); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Shuffle', params); + }); + + it('undoDraw sends Command_UndoDraw with empty object', () => { + undoDraw(gameId); + expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_UndoDraw', {}); + }); +}); diff --git a/webclient/src/websocket/events/common/commonEvents.spec.ts b/webclient/src/websocket/events/common/commonEvents.spec.ts index a080dc1e4..7dd74c37d 100644 --- a/webclient/src/websocket/events/common/commonEvents.spec.ts +++ b/webclient/src/websocket/events/common/commonEvents.spec.ts @@ -1,19 +1,7 @@ -jest.mock('../../persistence', () => ({ - SessionPersistence: { - playerPropertiesChanged: jest.fn(), - }, -})); +import { CommonEvents } from './index'; -import { SessionPersistence } from '../../persistence'; - -beforeEach(() => jest.clearAllMocks()); - -describe('playerPropertiesChanged', () => { - const { playerPropertiesChanged } = jest.requireActual('./playerPropertiesChanged'); - - it('delegates to SessionPersistence.playerPropertiesChanged', () => { - const payload = { gameId: 1, player: { playerId: 2 } } as any; - playerPropertiesChanged(payload); - expect(SessionPersistence.playerPropertiesChanged).toHaveBeenCalledWith(payload); +describe('CommonEvents', () => { + it('is an empty event map (all common events were moved to game/session events)', () => { + expect(CommonEvents).toEqual({}); }); }); diff --git a/webclient/src/websocket/events/game/gameEvents.spec.ts b/webclient/src/websocket/events/game/gameEvents.spec.ts index 7f4f9f6b5..82c78518d 100644 --- a/webclient/src/websocket/events/game/gameEvents.spec.ts +++ b/webclient/src/websocket/events/game/gameEvents.spec.ts @@ -1,21 +1,76 @@ jest.mock('../../persistence', () => ({ GamePersistence: { + gameStateChanged: jest.fn(), playerJoined: jest.fn(), playerLeft: jest.fn(), + playerPropertiesChanged: jest.fn(), + gameClosed: jest.fn(), + gameHostChanged: jest.fn(), + kicked: jest.fn(), + gameSay: jest.fn(), + cardMoved: jest.fn(), + cardFlipped: jest.fn(), + cardDestroyed: jest.fn(), + cardAttached: jest.fn(), + tokenCreated: jest.fn(), + cardAttrChanged: jest.fn(), + cardCounterChanged: jest.fn(), + arrowCreated: jest.fn(), + arrowDeleted: jest.fn(), + counterCreated: jest.fn(), + counterSet: jest.fn(), + counterDeleted: jest.fn(), + cardsDrawn: jest.fn(), + cardsRevealed: jest.fn(), + zoneShuffled: jest.fn(), + dieRolled: jest.fn(), + activePlayerSet: jest.fn(), + activePhaseSet: jest.fn(), + turnReversed: jest.fn(), + zoneDumped: jest.fn(), + zonePropertiesChanged: jest.fn(), }, })); import { GamePersistence } from '../../persistence'; +import { attachCard } from './attachCard'; +import { changeZoneProperties } from './changeZoneProperties'; +import { createArrow } from './createArrow'; +import { createCounter } from './createCounter'; +import { createToken } from './createToken'; +import { delCounter } from './delCounter'; +import { deleteArrow } from './deleteArrow'; +import { destroyCard } from './destroyCard'; +import { drawCards } from './drawCards'; +import { dumpZone } from './dumpZone'; +import { flipCard } from './flipCard'; +import { gameClosed } from './gameClosed'; +import { gameHostChanged } from './gameHostChanged'; +import { gameSay } from './gameSay'; +import { gameStateChanged } from './gameStateChanged'; import { joinGame } from './joinGame'; +import { kicked } from './kicked'; import { leaveGame } from './leaveGame'; +import { moveCard } from './moveCard'; +import { playerPropertiesChanged } from './playerPropertiesChanged'; +import { revealCards } from './revealCards'; +import { reverseTurn } from './reverseTurn'; +import { rollDie } from './rollDie'; +import { setActivePhase } from './setActivePhase'; +import { setActivePlayer } from './setActivePlayer'; +import { setCardAttr } from './setCardAttr'; +import { setCardCounter } from './setCardCounter'; +import { setCounter } from './setCounter'; +import { shuffle } from './shuffle'; beforeEach(() => jest.clearAllMocks()); +const meta = { gameId: 5, playerId: 2, context: null, secondsElapsed: 0, forcedByJudge: 0 }; + describe('joinGame event', () => { it('delegates to GamePersistence.playerJoined with gameId from meta', () => { const playerProperties = { playerId: 1 }; const data = { playerProperties } as any; - const meta = { gameId: 5, playerId: 1, context: null, secondsElapsed: 0, forcedByJudge: 0 }; joinGame(data, meta); expect(GamePersistence.playerJoined).toHaveBeenCalledWith(5, playerProperties); }); @@ -24,8 +79,221 @@ describe('joinGame event', () => { describe('leaveGame event', () => { it('delegates to GamePersistence.playerLeft with gameId/playerId from meta', () => { const data = { reason: 3 }; - const meta = { gameId: 5, playerId: 2, context: null, secondsElapsed: 0, forcedByJudge: 0 }; leaveGame(data, meta); expect(GamePersistence.playerLeft).toHaveBeenCalledWith(5, 2, 3); }); }); + +describe('gameClosed event', () => { + it('delegates to GamePersistence.gameClosed with gameId', () => { + gameClosed({}, meta); + expect(GamePersistence.gameClosed).toHaveBeenCalledWith(5); + }); +}); + +describe('gameHostChanged event', () => { + it('delegates to GamePersistence.gameHostChanged using meta.playerId as hostId', () => { + gameHostChanged({}, meta); + expect(GamePersistence.gameHostChanged).toHaveBeenCalledWith(5, 2); + }); +}); + +describe('kicked event', () => { + it('delegates to GamePersistence.kicked with gameId', () => { + kicked({}, meta); + expect(GamePersistence.kicked).toHaveBeenCalledWith(5); + }); +}); + +describe('gameStateChanged event', () => { + it('delegates to GamePersistence.gameStateChanged with gameId and full data', () => { + const data = { playerList: [] } as any; + gameStateChanged(data, meta); + expect(GamePersistence.gameStateChanged).toHaveBeenCalledWith(5, data); + }); +}); + +describe('playerPropertiesChanged event', () => { + it('delegates to GamePersistence.playerPropertiesChanged with gameId, playerId, properties', () => { + const playerProperties = { playerId: 2 } as any; + const data = { playerProperties } as any; + playerPropertiesChanged(data, meta); + expect(GamePersistence.playerPropertiesChanged).toHaveBeenCalledWith(5, 2, playerProperties); + }); +}); + +describe('gameSay event', () => { + it('delegates to GamePersistence.gameSay with gameId, playerId, message', () => { + const data = { message: 'gg' } as any; + gameSay(data, meta); + expect(GamePersistence.gameSay).toHaveBeenCalledWith(5, 2, 'gg'); + }); +}); + +describe('moveCard event', () => { + it('delegates to GamePersistence.cardMoved with gameId, playerId and data', () => { + const data = { cardId: 3 } as any; + moveCard(data, meta); + expect(GamePersistence.cardMoved).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('flipCard event', () => { + it('delegates to GamePersistence.cardFlipped with gameId, playerId and data', () => { + const data = { cardId: 3 } as any; + flipCard(data, meta); + expect(GamePersistence.cardFlipped).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('destroyCard event', () => { + it('delegates to GamePersistence.cardDestroyed with gameId, playerId and data', () => { + const data = { cardId: 3 } as any; + destroyCard(data, meta); + expect(GamePersistence.cardDestroyed).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('attachCard event', () => { + it('delegates to GamePersistence.cardAttached with gameId, playerId and data', () => { + const data = { cardId: 3 } as any; + attachCard(data, meta); + expect(GamePersistence.cardAttached).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('createToken event', () => { + it('delegates to GamePersistence.tokenCreated with gameId, playerId and data', () => { + const data = { cardId: 3 } as any; + createToken(data, meta); + expect(GamePersistence.tokenCreated).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('setCardAttr event', () => { + it('delegates to GamePersistence.cardAttrChanged with gameId, playerId and data', () => { + const data = { cardId: 3 } as any; + setCardAttr(data, meta); + expect(GamePersistence.cardAttrChanged).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('setCardCounter event', () => { + it('delegates to GamePersistence.cardCounterChanged with gameId, playerId and data', () => { + const data = { cardId: 3 } as any; + setCardCounter(data, meta); + expect(GamePersistence.cardCounterChanged).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('createArrow event', () => { + it('delegates to GamePersistence.arrowCreated with gameId, playerId and data', () => { + const data = { arrowInfo: {} } as any; + createArrow(data, meta); + expect(GamePersistence.arrowCreated).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('deleteArrow event', () => { + it('delegates to GamePersistence.arrowDeleted with gameId, playerId and data', () => { + const data = { arrowId: 9 } as any; + deleteArrow(data, meta); + expect(GamePersistence.arrowDeleted).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('createCounter event', () => { + it('delegates to GamePersistence.counterCreated with gameId, playerId and data', () => { + const data = { counterInfo: {} } as any; + createCounter(data, meta); + expect(GamePersistence.counterCreated).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('setCounter event', () => { + it('delegates to GamePersistence.counterSet with gameId, playerId and data', () => { + const data = { counterId: 1, value: 20 } as any; + setCounter(data, meta); + expect(GamePersistence.counterSet).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('delCounter event', () => { + it('delegates to GamePersistence.counterDeleted with gameId, playerId and data', () => { + const data = { counterId: 1 } as any; + delCounter(data, meta); + expect(GamePersistence.counterDeleted).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('drawCards event', () => { + it('delegates to GamePersistence.cardsDrawn with gameId, playerId and data', () => { + const data = { number: 2, cards: [] } as any; + drawCards(data, meta); + expect(GamePersistence.cardsDrawn).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('revealCards event', () => { + it('delegates to GamePersistence.cardsRevealed with gameId, playerId and data', () => { + const data = { zoneName: 'hand', cards: [] } as any; + revealCards(data, meta); + expect(GamePersistence.cardsRevealed).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('shuffle event', () => { + it('delegates to GamePersistence.zoneShuffled with gameId, playerId and data', () => { + const data = { zoneName: 'deck' } as any; + shuffle(data, meta); + expect(GamePersistence.zoneShuffled).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('rollDie event', () => { + it('delegates to GamePersistence.dieRolled with gameId, playerId and data', () => { + const data = { die: 6, result: 4 } as any; + rollDie(data, meta); + expect(GamePersistence.dieRolled).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('setActivePlayer event', () => { + it('delegates to GamePersistence.activePlayerSet with gameId and activePlayerId', () => { + const data = { activePlayerId: 3 } as any; + setActivePlayer(data, meta); + expect(GamePersistence.activePlayerSet).toHaveBeenCalledWith(5, 3); + }); +}); + +describe('setActivePhase event', () => { + it('delegates to GamePersistence.activePhaseSet with gameId and phase', () => { + const data = { phase: 4 } as any; + setActivePhase(data, meta); + expect(GamePersistence.activePhaseSet).toHaveBeenCalledWith(5, 4); + }); +}); + +describe('reverseTurn event', () => { + it('delegates to GamePersistence.turnReversed with gameId and reversed', () => { + const data = { reversed: true } as any; + reverseTurn(data, meta); + expect(GamePersistence.turnReversed).toHaveBeenCalledWith(5, true); + }); +}); + +describe('dumpZone event', () => { + it('delegates to GamePersistence.zoneDumped with gameId, playerId and data', () => { + const data = { zoneName: 'hand' } as any; + dumpZone(data, meta); + expect(GamePersistence.zoneDumped).toHaveBeenCalledWith(5, 2, data); + }); +}); + +describe('changeZoneProperties event', () => { + it('delegates to GamePersistence.zonePropertiesChanged with gameId, playerId and data', () => { + const data = { zoneName: 'hand', alwaysRevealTopCard: true } as any; + changeZoneProperties(data, meta); + expect(GamePersistence.zonePropertiesChanged).toHaveBeenCalledWith(5, 2, data); + }); +}); diff --git a/webclient/src/websocket/persistence/GamePersistence.spec.ts b/webclient/src/websocket/persistence/GamePersistence.spec.ts index 29377c339..43e7e86b0 100644 --- a/webclient/src/websocket/persistence/GamePersistence.spec.ts +++ b/webclient/src/websocket/persistence/GamePersistence.spec.ts @@ -2,8 +2,35 @@ import { GamePersistence } from './GamePersistence'; jest.mock('store', () => ({ GameDispatch: { + gameStateChanged: jest.fn(), playerJoined: jest.fn(), playerLeft: jest.fn(), + playerPropertiesChanged: jest.fn(), + gameClosed: jest.fn(), + gameHostChanged: jest.fn(), + kicked: jest.fn(), + gameSay: jest.fn(), + cardMoved: jest.fn(), + cardFlipped: jest.fn(), + cardDestroyed: jest.fn(), + cardAttached: jest.fn(), + tokenCreated: jest.fn(), + cardAttrChanged: jest.fn(), + cardCounterChanged: jest.fn(), + arrowCreated: jest.fn(), + arrowDeleted: jest.fn(), + counterCreated: jest.fn(), + counterSet: jest.fn(), + counterDeleted: jest.fn(), + cardsDrawn: jest.fn(), + cardsRevealed: jest.fn(), + zoneShuffled: jest.fn(), + dieRolled: jest.fn(), + activePlayerSet: jest.fn(), + activePhaseSet: jest.fn(), + turnReversed: jest.fn(), + zoneDumped: jest.fn(), + zonePropertiesChanged: jest.fn(), }, })); @@ -12,6 +39,12 @@ import { GameDispatch } from 'store'; beforeEach(() => jest.clearAllMocks()); describe('GamePersistence', () => { + it('gameStateChanged dispatches via GameDispatch', () => { + const data = { playerList: [] } as any; + GamePersistence.gameStateChanged(5, data); + expect(GameDispatch.gameStateChanged).toHaveBeenCalledWith(5, data); + }); + it('playerJoined dispatches via GameDispatch', () => { const data = { playerId: 1 } as any; GamePersistence.playerJoined(5, data); @@ -22,4 +55,153 @@ describe('GamePersistence', () => { GamePersistence.playerLeft(5, 1, 3); expect(GameDispatch.playerLeft).toHaveBeenCalledWith(5, 1, 3); }); + + it('playerPropertiesChanged dispatches via GameDispatch', () => { + const props = { playerId: 2 } as any; + GamePersistence.playerPropertiesChanged(5, 2, props); + expect(GameDispatch.playerPropertiesChanged).toHaveBeenCalledWith(5, 2, props); + }); + + it('gameClosed dispatches via GameDispatch', () => { + GamePersistence.gameClosed(5); + expect(GameDispatch.gameClosed).toHaveBeenCalledWith(5); + }); + + it('gameHostChanged dispatches via GameDispatch', () => { + GamePersistence.gameHostChanged(5, 7); + expect(GameDispatch.gameHostChanged).toHaveBeenCalledWith(5, 7); + }); + + it('kicked dispatches via GameDispatch', () => { + GamePersistence.kicked(5); + expect(GameDispatch.kicked).toHaveBeenCalledWith(5); + }); + + it('gameSay dispatches via GameDispatch', () => { + GamePersistence.gameSay(5, 1, 'hello'); + expect(GameDispatch.gameSay).toHaveBeenCalledWith(5, 1, 'hello'); + }); + + it('cardMoved dispatches via GameDispatch', () => { + const data = { cardId: 3 } as any; + GamePersistence.cardMoved(5, 1, data); + expect(GameDispatch.cardMoved).toHaveBeenCalledWith(5, 1, data); + }); + + it('cardFlipped dispatches via GameDispatch', () => { + const data = { cardId: 3 } as any; + GamePersistence.cardFlipped(5, 1, data); + expect(GameDispatch.cardFlipped).toHaveBeenCalledWith(5, 1, data); + }); + + it('cardDestroyed dispatches via GameDispatch', () => { + const data = { cardId: 3 } as any; + GamePersistence.cardDestroyed(5, 1, data); + expect(GameDispatch.cardDestroyed).toHaveBeenCalledWith(5, 1, data); + }); + + it('cardAttached dispatches via GameDispatch', () => { + const data = { cardId: 3 } as any; + GamePersistence.cardAttached(5, 1, data); + expect(GameDispatch.cardAttached).toHaveBeenCalledWith(5, 1, data); + }); + + it('tokenCreated dispatches via GameDispatch', () => { + const data = { cardId: 3 } as any; + GamePersistence.tokenCreated(5, 1, data); + expect(GameDispatch.tokenCreated).toHaveBeenCalledWith(5, 1, data); + }); + + it('cardAttrChanged dispatches via GameDispatch', () => { + const data = { cardId: 3 } as any; + GamePersistence.cardAttrChanged(5, 1, data); + expect(GameDispatch.cardAttrChanged).toHaveBeenCalledWith(5, 1, data); + }); + + it('cardCounterChanged dispatches via GameDispatch', () => { + const data = { cardId: 3 } as any; + GamePersistence.cardCounterChanged(5, 1, data); + expect(GameDispatch.cardCounterChanged).toHaveBeenCalledWith(5, 1, data); + }); + + it('arrowCreated dispatches via GameDispatch', () => { + const data = { arrowInfo: {} } as any; + GamePersistence.arrowCreated(5, 1, data); + expect(GameDispatch.arrowCreated).toHaveBeenCalledWith(5, 1, data); + }); + + it('arrowDeleted dispatches via GameDispatch', () => { + const data = { arrowId: 9 }; + GamePersistence.arrowDeleted(5, 1, data); + expect(GameDispatch.arrowDeleted).toHaveBeenCalledWith(5, 1, data); + }); + + it('counterCreated dispatches via GameDispatch', () => { + const data = { counterInfo: {} } as any; + GamePersistence.counterCreated(5, 1, data); + expect(GameDispatch.counterCreated).toHaveBeenCalledWith(5, 1, data); + }); + + it('counterSet dispatches via GameDispatch', () => { + const data = { counterId: 1, value: 20 }; + GamePersistence.counterSet(5, 1, data); + expect(GameDispatch.counterSet).toHaveBeenCalledWith(5, 1, data); + }); + + it('counterDeleted dispatches via GameDispatch', () => { + const data = { counterId: 1 }; + GamePersistence.counterDeleted(5, 1, data); + expect(GameDispatch.counterDeleted).toHaveBeenCalledWith(5, 1, data); + }); + + it('cardsDrawn dispatches via GameDispatch', () => { + const data = { number: 2, cards: [] } as any; + GamePersistence.cardsDrawn(5, 1, data); + expect(GameDispatch.cardsDrawn).toHaveBeenCalledWith(5, 1, data); + }); + + it('cardsRevealed dispatches via GameDispatch', () => { + const data = { zoneName: 'hand', cards: [] } as any; + GamePersistence.cardsRevealed(5, 1, data); + expect(GameDispatch.cardsRevealed).toHaveBeenCalledWith(5, 1, data); + }); + + it('zoneShuffled dispatches via GameDispatch', () => { + const data = { zoneName: 'deck' } as any; + GamePersistence.zoneShuffled(5, 1, data); + expect(GameDispatch.zoneShuffled).toHaveBeenCalledWith(5, 1, data); + }); + + it('dieRolled dispatches via GameDispatch', () => { + const data = { die: 6, result: 4 } as any; + GamePersistence.dieRolled(5, 1, data); + expect(GameDispatch.dieRolled).toHaveBeenCalledWith(5, 1, data); + }); + + it('activePlayerSet dispatches via GameDispatch', () => { + GamePersistence.activePlayerSet(5, 2); + expect(GameDispatch.activePlayerSet).toHaveBeenCalledWith(5, 2); + }); + + it('activePhaseSet dispatches via GameDispatch', () => { + GamePersistence.activePhaseSet(5, 3); + expect(GameDispatch.activePhaseSet).toHaveBeenCalledWith(5, 3); + }); + + it('turnReversed dispatches via GameDispatch', () => { + GamePersistence.turnReversed(5, true); + expect(GameDispatch.turnReversed).toHaveBeenCalledWith(5, true); + }); + + it('zoneDumped dispatches via GameDispatch', () => { + const data = { zoneName: 'hand' } as any; + GamePersistence.zoneDumped(5, 1, data); + expect(GameDispatch.zoneDumped).toHaveBeenCalledWith(5, 1, data); + }); + + it('zonePropertiesChanged dispatches via GameDispatch', () => { + const data = { zoneName: 'hand', alwaysRevealTopCard: true } as any; + GamePersistence.zonePropertiesChanged(5, 1, data); + expect(GameDispatch.zonePropertiesChanged).toHaveBeenCalledWith(5, 1, data); + }); }); diff --git a/webclient/src/websocket/persistence/SessionPersistence.spec.ts b/webclient/src/websocket/persistence/SessionPersistence.spec.ts index 38080d578..ff83f7684 100644 --- a/webclient/src/websocket/persistence/SessionPersistence.spec.ts +++ b/webclient/src/websocket/persistence/SessionPersistence.spec.ts @@ -309,11 +309,10 @@ describe('SessionPersistence', () => { spy.mockRestore(); }); - it('gameJoined logs to console', () => { - const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); - SessionPersistence.gameJoined({ gameInfo: {} } as any); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); + it('gameJoined dispatches via GameDispatch.gameJoined', () => { + const gameInfo = { gameId: 10, roomId: 2, description: 'test', started: false }; + SessionPersistence.gameJoined({ gameInfo, hostId: 3, playerId: 4, spectator: false, judge: false } as any); + expect(GameDispatch.gameJoined).toHaveBeenCalledWith(10, expect.objectContaining({ gameId: 10, hostId: 3, localPlayerId: 4 })); }); it('notifyUser passes notification', () => { diff --git a/webclient/src/websocket/services/BackendService.spec.ts b/webclient/src/websocket/services/BackendService.spec.ts index bdc92c2a1..1619888dc 100644 --- a/webclient/src/websocket/services/BackendService.spec.ts +++ b/webclient/src/websocket/services/BackendService.spec.ts @@ -6,6 +6,7 @@ jest.mock('./ProtoController', () => ({ jest.mock('../WebClient', () => { const mockProtobuf = { + sendGameCommand: jest.fn(), sendSessionCommand: jest.fn(), sendRoomCommand: jest.fn(), sendModeratorCommand: jest.fn(), @@ -21,6 +22,8 @@ import webClient from '../WebClient'; beforeEach(() => { jest.clearAllMocks(); ProtoController.root = makeMockProtoRoot(); + ProtoController.root.GameCommand = { create: jest.fn(args => ({ ...args })) }; + ProtoController.root['Command_Game'] = { create: jest.fn(p => ({ ...p })) }; ProtoController.root['Command_Test'] = { create: jest.fn(p => ({ ...p })) }; ProtoController.root['Command_Room'] = { create: jest.fn(p => ({ ...p })) }; ProtoController.root['Command_Mod'] = { create: jest.fn(p => ({ ...p })) }; @@ -35,6 +38,7 @@ function captureCallback(sendFn: jest.Mock) { describe('BackendService', () => { describe('send commands', () => { it.each([ + ['sendGameCommand', () => BackendService.sendGameCommand(7, 'Command_Game', { g: 1 })], ['sendSessionCommand', () => BackendService.sendSessionCommand('Command_Test', { x: 1 }, {})], ['sendRoomCommand', () => BackendService.sendRoomCommand(5, 'Command_Room', { y: 2 }, {})], ['sendModeratorCommand', () => BackendService.sendModeratorCommand('Command_Mod', { z: 3 }, {})], @@ -46,6 +50,14 @@ describe('BackendService', () => { }); describe('handleResponse via non-session command callbacks', () => { + it('sendGameCommand callback invokes handleResponse', () => { + const onSuccess = jest.fn(); + BackendService.sendGameCommand(7, 'Command_Game', {}, { onSuccess }); + const cb = (webClient.protobuf as any).sendGameCommand.mock.calls[0][2]; + cb({ responseCode: 0 }); + expect(onSuccess).toHaveBeenCalled(); + }); + it('sendRoomCommand callback invokes handleResponse', () => { const onSuccess = jest.fn(); BackendService.sendRoomCommand(5, 'Command_Room', {}, { onSuccess }); diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index d0cfab31a..618f643a5 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -10,7 +10,7 @@ jest.mock('../commands/session', () => ({ })); jest.mock('../events', () => ({ - CommonEvents: {}, + CommonEvents: { '.Event_Common.ext': jest.fn() }, GameEvents: { '.Event_Game.ext': jest.fn() }, RoomEvents: { '.Event_Room.ext': jest.fn() }, SessionEvents: { '.Event_Session.ext': jest.fn() }, @@ -21,6 +21,7 @@ jest.mock('../WebClient'); import { ProtobufService } from './ProtobufService'; import { ProtoController } from './ProtoController'; import { ping as sessionPing } from '../commands/session'; +import { GameEvents, CommonEvents } from '../events'; let mockSocket: any; let mockWebClient: any; @@ -143,6 +144,35 @@ describe('ProtobufService', () => { }); }); + describe('sendGameCommand', () => { + it('creates a CommandContainer with gameId and gameCommand', () => { + const service = new ProtobufService(mockWebClient); + service.sendGameCommand(7, { gameCmdType: 'test' }, jest.fn()); + expect(ProtoController.root.CommandContainer.create).toHaveBeenCalledWith( + expect.objectContaining({ gameId: 7, gameCommand: expect.anything() }) + ); + }); + + it('invokes callback with raw response when the pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + const cb = jest.fn(); + service.sendGameCommand(7, { gameCmdType: 'test' }, cb); + + const storedCb = (service as any).pendingCommands[1]; + storedCb({ responseData: true }); + + expect(cb).toHaveBeenCalledWith({ responseData: true }); + }); + + it('does not throw when no callback is provided and pending command is triggered', () => { + const service = new ProtobufService(mockWebClient); + service.sendGameCommand(7, { gameCmdType: 'test' }); + + const storedCb = (service as any).pendingCommands[1]; + expect(() => storedCb({ responseData: true })).not.toThrow(); + }); + }); + describe('sendModeratorCommand', () => { it('creates a CommandContainer with moderatorCommand', () => { const service = new ProtobufService(mockWebClient); @@ -291,6 +321,54 @@ describe('ProtobufService', () => { }); }); + describe('processCommonEvent', () => { + it('delegates to processEvent with CommonEvents', () => { + const service = new ProtobufService(mockWebClient); + const processEvent = jest.spyOn(service as any, 'processEvent'); + const response = { '.Event_Common.ext': { data: 1 } }; + const raw = { extra: true }; + (service as any).processCommonEvent(response, raw); + expect(processEvent).toHaveBeenCalledWith(response, CommonEvents, raw); + }); + }); + + describe('processGameEvent', () => { + it('returns early when container has no eventList', () => { + const service = new ProtobufService(mockWebClient); + const gameEventHandler = (GameEvents as any)['.Event_Game.ext'] as jest.Mock; + (service as any).processGameEvent(null, {}); + expect(gameEventHandler).not.toHaveBeenCalled(); + }); + + it('dispatches to a GameEvents handler when event key matches', () => { + const service = new ProtobufService(mockWebClient); + const gameEventHandler = (GameEvents as any)['.Event_Game.ext'] as jest.Mock; + const payload = { someData: 1 }; + (service as any).processGameEvent({ + gameId: 42, + context: null, + secondsElapsed: 10, + forcedByJudge: 0, + eventList: [{ '.Event_Game.ext': payload, playerId: 5 }], + }, {}); + expect(gameEventHandler).toHaveBeenCalledWith(payload, expect.objectContaining({ gameId: 42, playerId: 5 })); + }); + + it('falls back to CommonEvents handler when no GameEvents key matches', () => { + const service = new ProtobufService(mockWebClient); + const commonEventHandler = (CommonEvents as any)['.Event_Common.ext'] as jest.Mock; + const payload = { commonData: 2 }; + (service as any).processGameEvent({ + gameId: 7, + context: null, + secondsElapsed: 0, + forcedByJudge: 0, + eventList: [{ '.Event_Common.ext': payload, playerId: 3 }], + }, {}); + expect(commonEventHandler).toHaveBeenCalledWith(payload, expect.objectContaining({ gameId: 7, playerId: 3 })); + }); + }); + describe('processEvent', () => { it('calls matching event handler with payload and raw', () => { const service = new ProtobufService(mockWebClient);