Skip to content

Commit

Permalink
Merge 0f1888f into dc6cd45
Browse files Browse the repository at this point in the history
  • Loading branch information
shaoster committed May 21, 2021
2 parents dc6cd45 + 0f1888f commit daf745c
Show file tree
Hide file tree
Showing 11 changed files with 342 additions and 42 deletions.
92 changes: 71 additions & 21 deletions src/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* https://opensource.org/licenses/MIT.
*/

import { INVALID_MOVE } from '../core/constants';
import { createStore } from 'redux';
import { CreateGameReducer } from '../core/reducer';
import { InitializeGame } from '../core/initialize';
Expand Down Expand Up @@ -498,6 +499,45 @@ describe('move dispatchers', () => {
});
});

describe('transient handling', () => {
let client = null;

beforeEach(() => {
client = Client({
game: {
moves: {
A: () => ({}),
Invalid: () => INVALID_MOVE,
},
},
});
});

test('regular move', () => {
const result = client.moves.A();
// TODO(#723): Check against a successful ActionResult.
expect(result).toBeUndefined();
const state = client.store.getState();
// Slightly paranoid check to ensure we don't erroneously add transients.
expect(state).toEqual(
expect.not.objectContaining({ transients: expect.anything() })
);
});

test('invalid move', () => {
const result = client.moves.Invalid();
// TODO(#723): Check against an errored ActionResult.
expect(result).toBeUndefined();
const state = client.store.getState();
// Ensure we've stripped the transients automagically.
// At the time this test was written, this effectively ensures that Client
// hooks up the TransientHandlingMiddleware correctly.
expect(state).toEqual(
expect.not.objectContaining({ transients: expect.anything() })
);
});
});

describe('log handling', () => {
let client = null;

Expand Down Expand Up @@ -769,52 +809,62 @@ test('override game state', () => {
expect(client.getState().G).toEqual({ moved: true });
});

// TODO(#941): These tests should validate DOM mounting/unmounting.
describe('start / stop', () => {
beforeEach(() => {
// Don't let other calls to `error` pollute this state.
jest.resetAllMocks();
});

test('mount on custom element', () => {
const el = document.createElement('div');
const client = Client({ game: {}, debug: { target: el } });
client.start();
client.stop();
expect(error).toHaveBeenCalledTimes(3);
expect(error).toHaveBeenCalledWith('disallowed move: B');
expect(error).toHaveBeenCalledWith('disallowed move: C');
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
expect(() => {
client.start();
client.stop();
}).not.toThrow();
expect(error).not.toHaveBeenCalled();
});

test('no error when mounting on null element', () => {
const client = Client({ game: {}, debug: { target: null } }) as any;
expect(() => {
client.start();
client.stop();
}).not.toThrow();

client.start();
client.stop();
expect(client.manager.debugPanel).toBe(null);
});

test('override debug implementation', () => {
const client = Client({ game: {}, debug: { impl: Debug } });
expect(() => {
client.start();
client.stop();
}).not.toThrow();

client.start();
client.stop();
expect(error).toHaveBeenCalledTimes(3);
expect(error).toHaveBeenCalledWith('disallowed move: B');
expect(error).toHaveBeenCalledWith('disallowed move: C');
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
expect(error).not.toHaveBeenCalled();
});

test('production mode', () => {
process.env.NODE_ENV = 'production';
const client = Client({ game: {} });
client.start();
client.stop();
expect(error).toHaveBeenCalledTimes(3);
expect(error).toHaveBeenCalledWith('disallowed move: B');
expect(error).toHaveBeenCalledWith('disallowed move: C');
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
expect(() => {
client.start();
client.stop();
}).not.toThrow();
expect(error).not.toHaveBeenCalled();
});

test('try to stop without starting', () => {
const client = Client({ game: {} });
client.stop();
expect(error).toHaveBeenCalledTimes(3);
expect(error).toHaveBeenCalledWith('disallowed move: B');
expect(error).toHaveBeenCalledWith('disallowed move: C');
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
expect(() => {
client.stop();
}).not.toThrow();
expect(error).not.toHaveBeenCalled();
});
});
6 changes: 5 additions & 1 deletion src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import * as Actions from '../core/action-types';
import * as ActionCreators from '../core/action-creators';
import { ProcessGameConfig } from '../core/game';
import type Debug from './debug/Debug.svelte';
import { CreateGameReducer } from '../core/reducer';
import {
CreateGameReducer,
TransientHandlingMiddleware,
} from '../core/reducer';
import { InitializeGame } from '../core/initialize';
import { PlayerView } from '../plugins/main';
import type { Transport, TransportOpts } from './transport/transport';
Expand Down Expand Up @@ -303,6 +306,7 @@ export class _ClientImpl<G extends any = any> {
};

const middleware = applyMiddleware(
TransientHandlingMiddleware,
SubscriptionMiddleware,
TransportMiddleware,
LogMiddleware
Expand Down
8 changes: 8 additions & 0 deletions src/core/action-creators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,11 @@ export const plugin = (
type: Actions.PLUGIN as typeof Actions.PLUGIN,
payload: { type, args, playerID, credentials },
});

/**
* Private action used to strip transient metadata (e.g. errors) from the game
* state.
*/
export const stripTransients = () => ({
type: Actions.STRIP_TRANSIENTS as typeof Actions.STRIP_TRANSIENTS,
});
1 change: 1 addition & 0 deletions src/core/action-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export const UNDO = 'UNDO';
export const UPDATE = 'UPDATE';
export const PATCH = 'PATCH';
export const PLUGIN = 'PLUGIN';
export const STRIP_TRANSIENTS = 'STRIP_TRANSIENTS';
31 changes: 31 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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.
*/

export enum UpdateErrorType {
// The action’s credentials were missing or invalid
UnauthorizedAction = 'update/unauthorized_action',
// The action’s matchID was not found
MatchNotFound = 'update/match_not_found',
// Could not apply Patch operation (rfc6902).
PatchFailed = 'update/patch_failed',
}

export enum ActionErrorType {
// The action contained a stale state ID
StaleStateId = 'action/stale_state_id',
// The requested move is unknown or not currently available
UnavailableMove = 'action/unavailable_move',
// The move declared it was invalid (INVALID_MOVE constant)
InvalidMove = 'action/invalid_move',
// The player making the action is not currently active
InactivePlayer = 'action/inactive_player',
// The game has finished
GameOver = 'action/gameover',
// The requested action is disabled (e.g. undo/redo, events)
ActionDisabled = 'action/action_disabled',
}
41 changes: 39 additions & 2 deletions src/core/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*/

import { INVALID_MOVE } from './constants';
import { CreateGameReducer } from './reducer';
import { applyMiddleware, createStore } from 'redux';
import { CreateGameReducer, TransientHandlingMiddleware } from './reducer';
import { InitializeGame } from './initialize';
import {
makeMove,
Expand All @@ -32,6 +33,7 @@ const game: Game = {
A: (G) => G,
B: () => ({ moved: true }),
C: () => ({ victory: true }),
Invalid: () => INVALID_MOVE,
},
endIf: (G, ctx) => (G.victory ? ctx.currentPlayer : undefined),
};
Expand Down Expand Up @@ -131,11 +133,16 @@ test('valid patch', () => {

test('invalid patch', () => {
const originalState = { _stateID: 0, G: 'patch' } as State;
const state = reducer(
const { transients, ...state } = reducer(
originalState,
patch(0, 1, [{ op: 'replace', path: '/_stateIDD', value: 1 }], [])
);
expect(state).toEqual(originalState);
expect(transients.error.type).toEqual('update/patch_failed');
// It's an array.
expect(transients.error.payload.length).toEqual(1);
// It looks like the standard rfc6902 error language.
expect(transients.error.payload[0].toString()).toContain('/_stateIDD');
});

test('reset', () => {
Expand Down Expand Up @@ -749,3 +756,33 @@ describe('undo / redo with stages', () => {
expect(state.ctx.activePlayers['0']).toBe('B');
});
});

describe('TransientHandlingMiddleware', () => {
const middleware = applyMiddleware(TransientHandlingMiddleware);
let store = null;

beforeEach(() => {
store = createStore(reducer, initialState, middleware);
});

test('regular dispatch result has no transients', () => {
const result = store.dispatch(makeMove('A'));
expect(result).toEqual(
expect.not.objectContaining({ transients: expect.anything() })
);
expect(result).toEqual(
expect.not.objectContaining({ stripTransientsResult: expect.anything() })
);
});

test('failing dispatch result contains transients', () => {
const result = store.dispatch(makeMove('Invalid'));
expect(result).toMatchObject({
transients: {
error: {
type: 'action/invalid_move',
},
},
});
});
});
Loading

0 comments on commit daf745c

Please sign in to comment.