UNPKG

boardgame.io

Version:
698 lines (593 loc) 16.8 kB
/* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import { createStore } from 'redux'; import { CreateGameReducer } from '../core/reducer'; import { InitializeGame } from '../core/initialize'; import { Client, createMoveDispatchers } from './client'; import { ProcessGameConfig } from '../core/game'; import { Transport } from './transport/transport'; import { LocalTransport, Local } from './transport/local'; import { SocketIOTransport, SocketIO } from './transport/socketio'; import { update, sync, makeMove, gameEvent } from '../core/action-creators'; import Debug from './debug/Debug.svelte'; import { error } from '../core/logger'; import { LogEntry, State, SyncInfo } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); describe('basic', () => { let client; let initial = { initial: true }; const game = { setup: () => initial, }; beforeAll(() => { client = Client({ game }); }); test('getState', () => { expect(client.getState().G).toEqual(initial); }); test('getInitialState', () => { expect(client.getInitialState().G).toEqual(initial); }); }); test('move api', () => { const client = Client({ game: { moves: { A: (G, ctx, arg) => ({ arg }), }, }, }); expect(client.getState().G).toEqual({}); client.moves.A(42); expect(client.getState().G).toEqual({ arg: 42 }); }); describe('namespaced moves', () => { let client; beforeAll(() => { client = Client({ game: { moves: { A: () => 'A', }, phases: { PA: { moves: { B: () => 'B', C: () => 'C', }, }, }, }, }); }); test('top-level moves', () => { expect(client.moves.A).toBeInstanceOf(Function); }); test('phase-level moves', () => { expect(client.moves.B).toBeInstanceOf(Function); expect(client.moves.C).toBeInstanceOf(Function); }); test('moves are allowed only when phase is active', () => { client.moves.A(); expect(client.getState().G).toEqual('A'); client.moves.B(); expect(error).toHaveBeenCalledWith('disallowed move: B'); client.moves.C(); expect(error).toHaveBeenCalledWith('disallowed move: C'); client.events.setPhase('PA'); expect(client.getState().ctx.phase).toBe('PA'); client.moves.A(); expect(error).toHaveBeenCalledWith('disallowed move: A'); client.moves.B(); expect(client.getState().G).toEqual('B'); client.moves.C(); expect(client.getState().G).toEqual('C'); }); }); test('isActive', () => { const client = Client({ game: { moves: { A: (G, ctx, arg) => ({ arg }), }, endIf: G => G.arg == 42, }, }); expect(client.getState().G).toEqual({}); expect(client.getState().isActive).toBe(true); client.moves.A(42); expect(client.getState().G).toEqual({ arg: 42 }); expect(client.getState().isActive).toBe(false); }); describe('multiplayer', () => { describe('socket.io master', () => { let host = 'host'; let port = '4321'; let client; beforeAll(() => { client = Client({ game: { moves: { A: () => {} } }, multiplayer: SocketIO({ server: host + ':' + port }), }); client.start(); }); afterAll(() => { jest.restoreAllMocks(); }); test('correct transport used', () => { expect(client.transport instanceof SocketIOTransport).toBe(true); }); test('server set when provided', () => { expect(client.transport.socket.io.engine.hostname).toEqual(host); expect(client.transport.socket.io.engine.port).toEqual(port); }); test('onAction called', () => { jest.spyOn(client.transport, 'onAction'); const state = { G: {}, ctx: { phase: '' }, plugins: {} }; const filteredMetadata = []; client.store.dispatch(sync({ state, filteredMetadata } as SyncInfo)); client.moves.A(); expect(client.transport.onAction).toHaveBeenCalled(); }); }); describe('multiplayer: SocketIO()', () => { let client; beforeAll(() => { client = Client({ game: {}, multiplayer: SocketIO(), }); client.start(); }); test('correct transport used', () => { expect(client.transport instanceof SocketIOTransport).toBe(true); }); }); describe('local master', () => { let client0; let client1; let spec; beforeAll(() => { spec = { game: { moves: { A: (G, ctx) => ({ A: ctx.playerID }) } }, multiplayer: Local(), }; client0 = Client({ ...spec, playerID: '0' }); client1 = Client({ ...spec, playerID: '1' }); client0.start(); client1.start(); }); test('correct transport used', () => { expect(client0.transport instanceof LocalTransport).toBe(true); expect(client1.transport instanceof LocalTransport).toBe(true); }); test('multiplayer interactions', () => { expect(client0.getState().ctx.currentPlayer).toBe('0'); expect(client1.getState().ctx.currentPlayer).toBe('0'); client0.moves.A(); expect(client0.getState().G).toEqual({ A: '0' }); expect(client1.getState().G).toEqual({ A: '0' }); client0.events.endTurn(); expect(client0.getState().ctx.currentPlayer).toBe('1'); expect(client1.getState().ctx.currentPlayer).toBe('1'); client1.moves.A(); expect(client0.getState().G).toEqual({ A: '1' }); expect(client1.getState().G).toEqual({ A: '1' }); }); }); describe('custom transport', () => { class CustomTransport { callback; constructor() { this.callback = null; } subscribeGameMetadata(fn) { this.callback = fn; } } const customTransport = () => (new CustomTransport() as unknown) as Transport; let client; beforeAll(() => { client = Client({ game: { moves: { A: () => {} } }, multiplayer: customTransport, }); }); test('correct transport used', () => { expect(client.transport).toBeInstanceOf(CustomTransport); }); test('metadata callback', () => { const metadata = { m: true }; client.transport.callback(metadata); expect(client.gameMetadata).toEqual(metadata); }); }); }); test('accepts enhancer for store', () => { let spyDispatcher; const spyEnhancer = vanillaCreateStore => (...args) => { const vanillaStore = vanillaCreateStore(...args); return { ...vanillaStore, dispatch: spyDispatcher = jest.fn(vanillaStore.dispatch), }; }; const client = Client({ game: { moves: { A: (G, ctx, arg) => ({ arg }), }, }, enhancer: spyEnhancer, }); expect(spyDispatcher.mock.calls).toHaveLength(0); client.moves.A(42); expect(spyDispatcher.mock.calls).toHaveLength(1); }); describe('event dispatchers', () => { test('default', () => { const game = {}; const client = Client({ game }); expect(Object.keys(client.events)).toEqual([ 'endTurn', 'pass', 'endPhase', 'setPhase', 'endGame', 'setActivePlayers', 'endStage', 'setStage', ]); expect(client.getState().ctx.turn).toBe(1); client.events.endTurn(); expect(client.getState().ctx.turn).toBe(2); }); test('all events', () => { const game = { events: { endPhase: true, endGame: true, }, }; const client = Client({ game }); expect(Object.keys(client.events)).toEqual([ 'endTurn', 'pass', 'endPhase', 'setPhase', 'endGame', 'setActivePlayers', 'endStage', 'setStage', ]); expect(client.getState().ctx.turn).toBe(1); client.events.endTurn(); expect(client.getState().ctx.turn).toBe(2); }); test('no events', () => { const game = { events: { endGame: false, endPhase: false, setPhase: false, endTurn: false, pass: false, setActivePlayers: false, endStage: false, setStage: false, }, }; const client = Client({ game }); expect(Object.keys(client.events)).toEqual([]); }); }); describe('move dispatchers', () => { const game = ProcessGameConfig({ moves: { A: G => G, B: (G, ctx) => ({ moved: ctx.playerID }), C: () => ({ victory: true }), }, endIf: (G, ctx) => (G.victory ? ctx.currentPlayer : undefined), }); const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); test('basic', () => { const store = createStore(reducer, initialState); const api = createMoveDispatchers(game.moveNames, store); expect(Object.getOwnPropertyNames(api)).toEqual(['A', 'B', 'C']); expect(api.unknown).toBe(undefined); api.A(); expect(store.getState().G).not.toMatchObject({ moved: true }); expect(store.getState().G).not.toMatchObject({ victory: true }); api.B(); expect(store.getState().G).toMatchObject({ moved: '0' }); store.dispatch(gameEvent('endTurn', null, '0')); api.B(); expect(store.getState().G).toMatchObject({ moved: '1' }); api.C(); expect(store.getState().G).toMatchObject({ victory: true }); }); test('with undefined playerID - singleplayer mode', () => { const store = createStore(reducer, initialState); const api = createMoveDispatchers(game.moveNames, store); api.B(); expect(store.getState().G).toMatchObject({ moved: '0' }); }); test('with undefined playerID - multiplayer mode', () => { const store = createStore(reducer, initialState); const api = createMoveDispatchers( game.moveNames, store, undefined, null, true ); api.B(); expect(store.getState().G).toMatchObject({ moved: undefined }); }); test('with null playerID - singleplayer mode', () => { const store = createStore(reducer, initialState); const api = createMoveDispatchers(game.moveNames, store, null); api.B(); expect(store.getState().G).toMatchObject({ moved: '0' }); }); test('with null playerID - multiplayer mode', () => { const store = createStore(reducer, initialState); const api = createMoveDispatchers(game.moveNames, store, null, null, true); api.B(); expect(store.getState().G).toMatchObject({ moved: null }); }); }); describe('log handling', () => { let client = null; beforeEach(() => { client = Client({ game: { moves: { A: () => ({}), }, }, }); }); test('regular', () => { client.moves.A(); client.moves.A(); expect(client.log).toEqual([ { action: makeMove('A', [], '0'), _stateID: 0, phase: null, turn: 1, }, { action: makeMove('A', [], '0'), _stateID: 1, phase: null, turn: 1, }, ]); }); test('update', () => { const state = ({ restore: true, _stateID: 0 } as unknown) as State; const deltalog = [ { action: {}, _stateID: 0, }, { action: {}, _stateID: 1, }, ] as LogEntry[]; const action = update(state, deltalog); client.store.dispatch(action); client.store.dispatch(action); expect(client.log).toEqual(deltalog); }); test('sync', () => { const state = { restore: true }; const log = ['0', '1']; const action = sync(({ state, log } as unknown) as SyncInfo); client.store.dispatch(action); client.store.dispatch(action); expect(client.log).toEqual(log); }); test('update - log missing', () => { const action = update(undefined, undefined); client.store.dispatch(action); expect(client.log).toEqual([]); }); test('sync - log missing', () => { const action = sync({} as SyncInfo); client.store.dispatch(action); expect(client.log).toEqual([]); }); }); describe('undo / redo', () => { const game = { moves: { A: (G, ctx, arg) => ({ arg }), }, }; test('basic', () => { const client = Client({ game }); expect(client.getState().G).toEqual({}); client.moves.A(42); expect(client.getState().G).toEqual({ arg: 42 }); client.undo(); expect(client.getState().G).toEqual({}); client.redo(); expect(client.getState().G).toEqual({ arg: 42 }); }); }); describe('subscribe', () => { let client; let fn; beforeAll(() => { const game = { moves: { A: G => { G.moved = true; }, }, }; client = Client({ game }); fn = jest.fn(); client.subscribe(fn); }); test('called at the beginning', () => { expect(fn).toBeCalledWith( expect.objectContaining({ G: {}, ctx: expect.objectContaining({ turn: 1 }), }) ); }); test('called after a move', () => { fn.mockClear(); client.moves.A(); expect(fn).toBeCalledWith( expect.objectContaining({ G: { moved: true }, }) ); }); test('called after an event', () => { fn.mockClear(); client.events.endTurn(); expect(fn).toBeCalledWith( expect.objectContaining({ ctx: expect.objectContaining({ turn: 2 }), }) ); }); test('multiple subscriptions', () => { fn.mockClear(); const fn2 = jest.fn(); const unsubscribe = client.subscribe(fn2); // The subscriber that just subscribed is notified. expect(fn).not.toBeCalled(); expect(fn2).toBeCalledWith( expect.objectContaining({ G: { moved: true }, }) ); fn.mockClear(); fn2.mockClear(); client.moves.A(); // Both subscribers are notified. expect(fn).toBeCalledWith( expect.objectContaining({ G: { moved: true }, }) ); expect(fn2).toBeCalledWith( expect.objectContaining({ G: { moved: true }, }) ); unsubscribe(); fn.mockClear(); fn2.mockClear(); // The subscriber the unsubscribed is not notified. client.moves.A(); expect(fn).toBeCalledWith( expect.objectContaining({ G: { moved: true }, }) ); expect(fn2).not.toBeCalled(); }); test('transport notifies subscribers', () => { let transport: ReturnType<ReturnType<typeof SocketIO>>; const multiplayer = (opts: any) => { transport = SocketIO()(opts); return transport; }; const client = Client({ game: {}, multiplayer }); const fn = jest.fn(); client.subscribe(fn); client.start(); fn.mockClear(); transport.callback(); expect(fn).toHaveBeenCalled(); }); describe('multiplayer', () => { test('subscribe before start', () => { const fn = jest.fn(); const client = Client({ game: {}, multiplayer: Local(), }); client.subscribe(fn); expect(fn).not.toBeCalled(); client.start(); expect(fn).toBeCalled(); }); test('subscribe after start', () => { const fn = jest.fn(); const client = Client({ game: {}, multiplayer: Local(), }); client.start(); client.subscribe(fn); expect(fn).toBeCalled(); }); }); }); test('override game state', () => { const game = { moves: { A: G => { G.moved = true; }, }, }; const client = Client({ game }); client.moves.A(); expect(client.getState().G).toEqual({ moved: true }); client.overrideGameState({ G: { override: true }, ctx: {} }); expect(client.getState().G).toEqual({ override: true }); client.overrideGameState(null); expect(client.getState().G).toEqual({ moved: true }); }); describe('start / stop', () => { test('mount on custom element', () => { const el = document.createElement('div'); const client = Client({ game: {}, debug: { target: el } }); client.start(); client.stop(); }); test('no error when mounting on null element', () => { const client = Client({ game: {}, debug: { target: null } }) as any; client.start(); client.stop(); expect(client._debugPanel).toBe(null); }); test('override debug implementation', () => { const client = Client({ game: {}, debug: { impl: Debug } }); client.start(); client.stop(); }); test('production mode', () => { process.env.NODE_ENV = 'production'; const client = Client({ game: {} }); client.start(); client.stop(); }); test('try to stop without starting', () => { const client = Client({ game: {} }); client.stop(); }); });