complete unit testing of redux and api layers

This commit is contained in:
seavor 2026-04-12 12:53:51 -05:00
parent 367852866f
commit 3001925430
19 changed files with 2808 additions and 5 deletions

View file

@ -0,0 +1,48 @@
jest.mock('websocket', () => ({
AdminCommands: {
adjustMod: jest.fn(),
reloadConfig: jest.fn(),
shutdownServer: jest.fn(),
updateServerMessage: jest.fn(),
},
}));
import { AdminService } from './AdminService';
import { AdminCommands } from 'websocket';
beforeEach(() => jest.clearAllMocks());
describe('AdminService', () => {
describe('adjustMod', () => {
it('delegates to AdminCommands.adjustMod with all arguments', () => {
AdminService.adjustMod('alice', true, false);
expect(AdminCommands.adjustMod).toHaveBeenCalledWith('alice', true, false);
});
it('delegates with optional arguments omitted', () => {
AdminService.adjustMod('alice');
expect(AdminCommands.adjustMod).toHaveBeenCalledWith('alice', undefined, undefined);
});
});
describe('reloadConfig', () => {
it('delegates to AdminCommands.reloadConfig', () => {
AdminService.reloadConfig();
expect(AdminCommands.reloadConfig).toHaveBeenCalled();
});
});
describe('shutdownServer', () => {
it('delegates to AdminCommands.shutdownServer', () => {
AdminService.shutdownServer('maintenance', 10);
expect(AdminCommands.shutdownServer).toHaveBeenCalledWith('maintenance', 10);
});
});
describe('updateServerMessage', () => {
it('delegates to AdminCommands.updateServerMessage', () => {
AdminService.updateServerMessage();
expect(AdminCommands.updateServerMessage).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,145 @@
jest.mock('websocket', () => ({
SessionCommands: {
connect: jest.fn(),
disconnect: jest.fn(),
},
webClient: {
connectionAttemptMade: false,
},
}));
jest.mock('websocket/services/ProtoController', () => ({
ProtoController: {
root: {
ServerInfo_User: {
UserLevelFlag: {
IsModerator: 4,
},
},
},
},
}));
import { AuthenticationService } from './AuthenticationService';
import { SessionCommands, webClient } from 'websocket';
import { StatusEnum, WebSocketConnectOptions, WebSocketConnectReason } from 'types';
const testOptions: WebSocketConnectOptions = { host: 'localhost', port: '4748', userName: 'user', password: 'pw' };
beforeEach(() => jest.clearAllMocks());
describe('AuthenticationService', () => {
describe('login', () => {
it('calls SessionCommands.connect with LOGIN reason', () => {
AuthenticationService.login(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.LOGIN);
});
});
describe('testConnection', () => {
it('calls SessionCommands.connect with TEST_CONNECTION reason', () => {
AuthenticationService.testConnection(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.TEST_CONNECTION);
});
});
describe('register', () => {
it('calls SessionCommands.connect with REGISTER reason', () => {
AuthenticationService.register(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.REGISTER);
});
});
describe('activateAccount', () => {
it('calls SessionCommands.connect with ACTIVATE_ACCOUNT reason', () => {
AuthenticationService.activateAccount(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.ACTIVATE_ACCOUNT);
});
});
describe('resetPasswordRequest', () => {
it('calls SessionCommands.connect with PASSWORD_RESET_REQUEST reason', () => {
AuthenticationService.resetPasswordRequest(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET_REQUEST);
});
});
describe('resetPasswordChallenge', () => {
it('calls SessionCommands.connect with PASSWORD_RESET_CHALLENGE reason', () => {
AuthenticationService.resetPasswordChallenge(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
});
});
describe('resetPassword', () => {
it('calls SessionCommands.connect with PASSWORD_RESET reason', () => {
AuthenticationService.resetPassword(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET);
});
});
describe('disconnect', () => {
it('delegates to SessionCommands.disconnect', () => {
AuthenticationService.disconnect();
expect(SessionCommands.disconnect).toHaveBeenCalled();
});
});
describe('isConnected', () => {
it('returns true when state is LOGGED_IN', () => {
expect(AuthenticationService.isConnected(StatusEnum.LOGGED_IN)).toBe(true);
});
it('returns false when state is DISCONNECTED', () => {
expect(AuthenticationService.isConnected(StatusEnum.DISCONNECTED)).toBe(false);
});
it('returns false when state is CONNECTING', () => {
expect(AuthenticationService.isConnected(StatusEnum.CONNECTING)).toBe(false);
});
it('returns false when state is CONNECTED', () => {
expect(AuthenticationService.isConnected(StatusEnum.CONNECTED)).toBe(false);
});
it('returns false when state is LOGGING_IN', () => {
expect(AuthenticationService.isConnected(StatusEnum.LOGGING_IN)).toBe(false);
});
});
describe('isModerator', () => {
it('returns true when userLevel has the IsModerator bit set', () => {
expect(AuthenticationService.isModerator({ userLevel: 4 } as any)).toBe(true);
});
it('returns true when userLevel has IsModerator and other bits set', () => {
expect(AuthenticationService.isModerator({ userLevel: 7 } as any)).toBe(true);
});
it('returns false when userLevel does not have the IsModerator bit', () => {
expect(AuthenticationService.isModerator({ userLevel: 1 } as any)).toBe(false);
});
it('returns false for admin-only userLevel without moderator bit', () => {
expect(AuthenticationService.isModerator({ userLevel: 8 } as any)).toBe(false);
});
});
describe('isAdmin', () => {
it('returns undefined (not yet implemented)', () => {
expect(AuthenticationService.isAdmin()).toBeUndefined();
});
});
describe('connectionAttemptMade', () => {
it('returns webClient.connectionAttemptMade when false', () => {
(webClient as any).connectionAttemptMade = false;
expect(AuthenticationService.connectionAttemptMade()).toBe(false);
});
it('returns webClient.connectionAttemptMade when true', () => {
(webClient as any).connectionAttemptMade = true;
expect(AuthenticationService.connectionAttemptMade()).toBe(true);
});
});
});

View file

@ -0,0 +1,75 @@
jest.mock('websocket', () => ({
ModeratorCommands: {
banFromServer: jest.fn(),
getBanHistory: jest.fn(),
getWarnHistory: jest.fn(),
getWarnList: jest.fn(),
viewLogHistory: jest.fn(),
warnUser: jest.fn(),
},
}));
import { ModeratorService } from './ModeratorService';
import { ModeratorCommands } from 'websocket';
import { LogFilters } from 'types';
beforeEach(() => jest.clearAllMocks());
describe('ModeratorService', () => {
describe('banFromServer', () => {
it('delegates to ModeratorCommands.banFromServer with all arguments', () => {
ModeratorService.banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible reason', 'cid', 1);
expect(ModeratorCommands.banFromServer).toHaveBeenCalledWith(
30, 'alice', '1.2.3.4', 'reason', 'visible reason', 'cid', 1
);
});
it('delegates with only required argument', () => {
ModeratorService.banFromServer(60);
expect(ModeratorCommands.banFromServer).toHaveBeenCalledWith(
60, undefined, undefined, undefined, undefined, undefined, undefined
);
});
});
describe('getBanHistory', () => {
it('delegates to ModeratorCommands.getBanHistory', () => {
ModeratorService.getBanHistory('alice');
expect(ModeratorCommands.getBanHistory).toHaveBeenCalledWith('alice');
});
});
describe('getWarnHistory', () => {
it('delegates to ModeratorCommands.getWarnHistory', () => {
ModeratorService.getWarnHistory('alice');
expect(ModeratorCommands.getWarnHistory).toHaveBeenCalledWith('alice');
});
});
describe('getWarnList', () => {
it('delegates to ModeratorCommands.getWarnList', () => {
ModeratorService.getWarnList('mod1', 'alice', 'cid123');
expect(ModeratorCommands.getWarnList).toHaveBeenCalledWith('mod1', 'alice', 'cid123');
});
});
describe('viewLogHistory', () => {
it('delegates to ModeratorCommands.viewLogHistory', () => {
const filters: LogFilters = { dateRange: 7, userName: 'alice' };
ModeratorService.viewLogHistory(filters);
expect(ModeratorCommands.viewLogHistory).toHaveBeenCalledWith(filters);
});
});
describe('warnUser', () => {
it('delegates to ModeratorCommands.warnUser with all arguments', () => {
ModeratorService.warnUser('alice', 'spamming', 'cid', 5);
expect(ModeratorCommands.warnUser).toHaveBeenCalledWith('alice', 'spamming', 'cid', 5);
});
it('delegates with only required arguments', () => {
ModeratorService.warnUser('alice', 'spamming');
expect(ModeratorCommands.warnUser).toHaveBeenCalledWith('alice', 'spamming', undefined, undefined);
});
});
});

View file

@ -0,0 +1,37 @@
jest.mock('websocket', () => ({
SessionCommands: {
joinRoom: jest.fn(),
},
RoomCommands: {
leaveRoom: jest.fn(),
roomSay: jest.fn(),
},
}));
import { RoomsService } from './RoomsService';
import { RoomCommands, SessionCommands } from 'websocket';
beforeEach(() => jest.clearAllMocks());
describe('RoomsService', () => {
describe('joinRoom', () => {
it('delegates to SessionCommands.joinRoom', () => {
RoomsService.joinRoom(42);
expect(SessionCommands.joinRoom).toHaveBeenCalledWith(42);
});
});
describe('leaveRoom', () => {
it('delegates to RoomCommands.leaveRoom', () => {
RoomsService.leaveRoom(42);
expect(RoomCommands.leaveRoom).toHaveBeenCalledWith(42);
});
});
describe('roomSay', () => {
it('delegates to RoomCommands.roomSay', () => {
RoomsService.roomSay(42, 'hello room');
expect(RoomCommands.roomSay).toHaveBeenCalledWith(42, 'hello room');
});
});
});

View file

@ -0,0 +1,102 @@
jest.mock('websocket', () => ({
SessionCommands: {
addToBuddyList: jest.fn(),
removeFromBuddyList: jest.fn(),
addToIgnoreList: jest.fn(),
removeFromIgnoreList: jest.fn(),
accountPassword: jest.fn(),
accountEdit: jest.fn(),
accountImage: jest.fn(),
message: jest.fn(),
getUserInfo: jest.fn(),
getGamesOfUser: jest.fn(),
},
}));
import { SessionService } from './SessionService';
import { SessionCommands } from 'websocket';
beforeEach(() => jest.clearAllMocks());
describe('SessionService', () => {
describe('addToBuddyList', () => {
it('delegates to SessionCommands.addToBuddyList', () => {
SessionService.addToBuddyList('alice');
expect(SessionCommands.addToBuddyList).toHaveBeenCalledWith('alice');
});
});
describe('removeFromBuddyList', () => {
it('delegates to SessionCommands.removeFromBuddyList', () => {
SessionService.removeFromBuddyList('alice');
expect(SessionCommands.removeFromBuddyList).toHaveBeenCalledWith('alice');
});
});
describe('addToIgnoreList', () => {
it('delegates to SessionCommands.addToIgnoreList', () => {
SessionService.addToIgnoreList('bob');
expect(SessionCommands.addToIgnoreList).toHaveBeenCalledWith('bob');
});
});
describe('removeFromIgnoreList', () => {
it('delegates to SessionCommands.removeFromIgnoreList', () => {
SessionService.removeFromIgnoreList('bob');
expect(SessionCommands.removeFromIgnoreList).toHaveBeenCalledWith('bob');
});
});
describe('changeAccountPassword', () => {
it('delegates to SessionCommands.accountPassword with all arguments', () => {
SessionService.changeAccountPassword('oldPw', 'newPw', 'hashedPw');
expect(SessionCommands.accountPassword).toHaveBeenCalledWith('oldPw', 'newPw', 'hashedPw');
});
it('delegates without hashedNewPassword when omitted', () => {
SessionService.changeAccountPassword('oldPw', 'newPw');
expect(SessionCommands.accountPassword).toHaveBeenCalledWith('oldPw', 'newPw', undefined);
});
});
describe('changeAccountDetails', () => {
it('delegates to SessionCommands.accountEdit with all arguments', () => {
SessionService.changeAccountDetails('pw', 'Alice', 'alice@example.com', 'US');
expect(SessionCommands.accountEdit).toHaveBeenCalledWith('pw', 'Alice', 'alice@example.com', 'US');
});
it('delegates with only required argument', () => {
SessionService.changeAccountDetails('pw');
expect(SessionCommands.accountEdit).toHaveBeenCalledWith('pw', undefined, undefined, undefined);
});
});
describe('changeAccountImage', () => {
it('delegates to SessionCommands.accountImage', () => {
const image = new Uint8Array([1, 2, 3]);
SessionService.changeAccountImage(image);
expect(SessionCommands.accountImage).toHaveBeenCalledWith(image);
});
});
describe('sendDirectMessage', () => {
it('delegates to SessionCommands.message', () => {
SessionService.sendDirectMessage('alice', 'hello');
expect(SessionCommands.message).toHaveBeenCalledWith('alice', 'hello');
});
});
describe('getUserInfo', () => {
it('delegates to SessionCommands.getUserInfo', () => {
SessionService.getUserInfo('alice');
expect(SessionCommands.getUserInfo).toHaveBeenCalledWith('alice');
});
});
describe('getUserGames', () => {
it('delegates to SessionCommands.getGamesOfUser', () => {
SessionService.getUserGames('alice');
expect(SessionCommands.getGamesOfUser).toHaveBeenCalledWith('alice');
});
});
});

View file

@ -0,0 +1,40 @@
import { actionReducer } from './actionReducer';
describe('actionReducer', () => {
it('spreads the init action onto state and starts count at 1', () => {
const result = actionReducer(undefined, { type: '@@INIT' });
// actionReducer always spreads the action, so type reflects the dispatched action
expect(result.type).toBe('@@INIT');
expect(result.payload).toBeNull();
expect(result.meta).toBeNull();
expect(result.error).toBe(false);
expect(result.count).toBe(1);
});
it('spreads action onto state and increments count', () => {
const result = actionReducer(undefined, { type: 'MY_ACTION', payload: { id: 1 } });
expect(result.type).toBe('MY_ACTION');
expect(result.payload).toEqual({ id: 1 });
expect(result.count).toBe(1);
});
it('increments count on each dispatch', () => {
const state1 = actionReducer(undefined, { type: 'A' });
const state2 = actionReducer(state1, { type: 'B' });
const state3 = actionReducer(state2, { type: 'C' });
expect(state3.count).toBe(3);
});
it('preserves existing state fields not overridden by action', () => {
const initial = actionReducer(undefined, { type: 'FIRST', payload: 'original' });
const result = actionReducer(initial, { type: 'SECOND' });
expect(result.type).toBe('SECOND');
expect(result.count).toBe(2);
});
it('spreads action.meta and action.error from action onto state', () => {
const result = actionReducer(undefined, { type: 'ERR', meta: { source: 'api' }, error: true });
expect(result.meta).toEqual({ source: 'api' });
expect(result.error).toBe(true);
});
});

View file

@ -0,0 +1,178 @@
import { SortDirection } from 'types';
import SortUtil from './SortUtil';
// ── sortByField ───────────────────────────────────────────────────────────────
describe('sortByField', () => {
it('sorts string field ASC alphabetically', () => {
const arr = [{ name: 'Zane' }, { name: 'Alice' }, { name: 'Bob' }];
SortUtil.sortByField(arr, { field: 'name', order: SortDirection.ASC });
expect(arr.map(x => x.name)).toEqual(['Alice', 'Bob', 'Zane']);
});
it('sorts string field DESC reverse-alphabetically', () => {
const arr = [{ name: 'Alice' }, { name: 'Zane' }, { name: 'Bob' }];
SortUtil.sortByField(arr, { field: 'name', order: SortDirection.DESC });
expect(arr.map(x => x.name)).toEqual(['Zane', 'Bob', 'Alice']);
});
it('sorts number field ASC', () => {
const arr = [{ score: 30 }, { score: 10 }, { score: 20 }];
SortUtil.sortByField(arr, { field: 'score', order: SortDirection.ASC });
expect(arr.map(x => x.score)).toEqual([10, 20, 30]);
});
it('sorts number field DESC', () => {
const arr = [{ score: 10 }, { score: 30 }, { score: 20 }];
SortUtil.sortByField(arr, { field: 'score', order: SortDirection.DESC });
expect(arr.map(x => x.score)).toEqual([30, 20, 10]);
});
it('no-ops on empty array without error', () => {
expect(() => SortUtil.sortByField([], { field: 'name', order: SortDirection.ASC })).not.toThrow();
});
it('sorts with nested dot-notation field', () => {
const arr = [{ meta: { rank: 3 } }, { meta: { rank: 1 } }, { meta: { rank: 2 } }];
SortUtil.sortByField(arr, { field: 'meta.rank', order: SortDirection.ASC });
expect(arr.map(x => x.meta.rank)).toEqual([1, 2, 3]);
});
it('throws when field resolves to a non-string, non-number value', () => {
const arr = [{ data: {} }, { data: {} }];
expect(() => SortUtil.sortByField(arr, { field: 'data', order: SortDirection.ASC })).toThrow(
'SortField must resolve to either a string or number'
);
});
it('sorts empty-string values to the bottom when sorting ASC', () => {
const arr = [{ name: '' }, { name: 'Alice' }, { name: '' }];
SortUtil.sortByField(arr, { field: 'name', order: SortDirection.ASC });
expect(arr[0].name).toBe('Alice');
expect(arr[1].name).toBe('');
expect(arr[2].name).toBe('');
});
});
// ── sortByFields ──────────────────────────────────────────────────────────────
describe('sortByFields', () => {
it('sorts by the first key when all items have distinct first-key values', () => {
const arr = [
{ group: 'C', name: 'Zane' },
{ group: 'A', name: 'Bob' },
{ group: 'B', name: 'Alice' },
];
SortUtil.sortByFields(arr, [
{ field: 'group', order: SortDirection.ASC },
{ field: 'name', order: SortDirection.ASC },
]);
expect(arr.map(x => x.group)).toEqual(['A', 'B', 'C']);
});
it('breaks ties on primary key using secondary key', () => {
const arr = [
{ group: 'A', name: 'Zane' },
{ group: 'A', name: 'Alice' },
{ group: 'B', name: 'Bob' },
];
SortUtil.sortByFields(arr, [
{ field: 'group', order: SortDirection.ASC },
{ field: 'name', order: SortDirection.ASC },
]);
expect(arr[0]).toEqual({ group: 'A', name: 'Alice' });
expect(arr[1]).toEqual({ group: 'A', name: 'Zane' });
expect(arr[2]).toEqual({ group: 'B', name: 'Bob' });
});
it('no-ops on empty array', () => {
expect(() =>
SortUtil.sortByFields([], [{ field: 'name', order: SortDirection.ASC }])
).not.toThrow();
});
it('sorts by number field', () => {
const arr = [{ score: 3 }, { score: 1 }, { score: 2 }];
SortUtil.sortByFields(arr, [{ field: 'score', order: SortDirection.ASC }]);
expect(arr.map(x => x.score)).toEqual([1, 2, 3]);
});
it('returns 0 when all items tie on every sort key', () => {
const arr = [{ score: 5 }, { score: 5 }];
expect(() =>
SortUtil.sortByFields(arr, [{ field: 'score', order: SortDirection.ASC }])
).not.toThrow();
expect(arr).toHaveLength(2);
});
it('throws when field resolves to a non-string, non-number value', () => {
const arr = [{ data: {} }, { data: {} }];
expect(() =>
SortUtil.sortByFields(arr, [{ field: 'data', order: SortDirection.ASC }])
).toThrow('SortField must resolve to either a string or number');
});
});
// ── sortUsersByField ──────────────────────────────────────────────────────────
describe('sortUsersByField', () => {
it('sorts by userLevel DESC first, then name ASC', () => {
const users = [
{ name: 'Alice', userLevel: 1, accountageSecs: 0, privlevel: 0 },
{ name: 'Bob', userLevel: 8, accountageSecs: 0, privlevel: 0 },
{ name: 'Carol', userLevel: 1, accountageSecs: 0, privlevel: 0 },
];
SortUtil.sortUsersByField(users as any, { field: 'name', order: SortDirection.ASC });
expect(users[0].name).toBe('Bob');
expect(users[1].name).toBe('Alice');
expect(users[2].name).toBe('Carol');
});
it('no-ops on empty array', () => {
expect(() =>
SortUtil.sortUsersByField([], { field: 'name', order: SortDirection.ASC })
).not.toThrow();
});
it('returns 0 (stable) when two users tie on both userLevel and name', () => {
const users = [
{ name: 'Alice', userLevel: 1, accountageSecs: 0, privlevel: 0 },
{ name: 'Alice', userLevel: 1, accountageSecs: 0, privlevel: 0 },
];
expect(() =>
SortUtil.sortUsersByField(users as any, { field: 'name', order: SortDirection.ASC })
).not.toThrow();
expect(users).toHaveLength(2);
});
});
// ── toggleSortBy ──────────────────────────────────────────────────────────────
describe('toggleSortBy', () => {
it('same field + ASC → returns DESC', () => {
const result = SortUtil.toggleSortBy('name', { field: 'name', order: SortDirection.ASC });
expect(result).toEqual({ field: 'name', order: SortDirection.DESC });
});
it('same field + DESC → returns ASC', () => {
const result = SortUtil.toggleSortBy('name', { field: 'name', order: SortDirection.DESC });
expect(result).toEqual({ field: 'name', order: SortDirection.ASC });
});
it('different field → returns ASC regardless of current order', () => {
const result = SortUtil.toggleSortBy('score', { field: 'name', order: SortDirection.DESC });
expect(result).toEqual({ field: 'score', order: SortDirection.ASC });
});
});
// ── resolveFieldChain with numeric index ─────────────────────────────────────
describe('resolveFieldChain via sortByField (numeric index)', () => {
it('resolves numeric index in dot-notation chain', () => {
const arr = [{ items: ['c', 'b', 'a'] }, { items: ['z', 'y', 'x'] }];
// Sort by items.0 which is the first element of the items array
SortUtil.sortByField(arr, { field: 'items.0', order: SortDirection.ASC });
expect(arr[0].items[0]).toBe('c');
expect(arr[1].items[0]).toBe('z');
});
});

View file

@ -35,17 +35,15 @@ export default class SortUtil {
if (result) {
return result;
}
}
if (fieldType === 'number') {
} else if (fieldType === 'number') {
const result = SortUtil.numberComparator(a, b, sortBy);
if (result) {
return result;
}
} else {
throw new Error('SortField must resolve to either a string or number');
}
throw new Error('SortField must resolve to either a string or number');
}
return 0;

View file

@ -383,6 +383,31 @@ describe('2C: CARD_MOVED', () => {
expect(moved.name).toBe('New Name');
expect(moved.providerId).toBe('new-prov');
});
it('CARD_MOVED → returns newState (card removed from source) when targetZone does not exist on player', () => {
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: 'nonexistent',
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[1].zones['nonexistent']).toBeUndefined();
});
});
// ── 2D: Card mutations ────────────────────────────────────────────────────────

View file

@ -0,0 +1,82 @@
import {
Game,
GameSortField,
Room,
SortDirection,
User,
UserPrivLevel,
UserSortField,
} from 'types';
import { Message, RoomsState } from '../rooms.interfaces';
export function makeUser(overrides: Partial<User> = {}): User {
return {
name: 'TestUser',
accountageSecs: 0,
privlevel: UserPrivLevel.NONE,
userLevel: 0,
...overrides,
};
}
export function makeRoom(overrides: Partial<Room> = {}): Room {
return {
roomId: 1,
name: 'Test Room',
description: '',
gameCount: 0,
gameList: [],
gametypeList: [],
gametypeMap: {},
autoJoin: false,
permissionlevel: 0 as any,
playerCount: 0,
privilegelevel: 0 as any,
userList: [],
order: 0,
...overrides,
};
}
export function makeGame(overrides: Partial<Game & { startTime: number }> = {}): Game & { startTime: number } {
return {
gameId: 1,
roomId: 1,
description: 'Test Game',
gameType: '',
gameTypes: [],
started: false,
startTime: 0,
...overrides,
};
}
export function makeMessage(overrides: Partial<Message> = {}): Message {
return {
message: 'hello',
messageType: 0,
timeReceived: 0,
...overrides,
};
}
export function makeRoomsState(overrides: Partial<RoomsState> = {}): RoomsState {
return {
rooms: {
1: makeRoom({ roomId: 1 }),
},
games: {},
joinedRoomIds: {},
joinedGameIds: {},
messages: {},
sortGamesBy: {
field: GameSortField.START_TIME,
order: SortDirection.DESC,
},
sortUsersBy: {
field: UserSortField.NAME,
order: SortDirection.ASC,
},
...overrides,
};
}

View file

@ -0,0 +1,69 @@
import { Actions } from './rooms.actions';
import { Types } from './rooms.types';
import { makeGame, makeMessage, makeRoom, makeUser } from './__mocks__/rooms-fixtures';
import { GameSortField, SortDirection } from 'types';
describe('Actions', () => {
it('clearStore', () => {
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE });
});
it('updateRooms', () => {
const rooms = [makeRoom()];
expect(Actions.updateRooms(rooms)).toEqual({ type: Types.UPDATE_ROOMS, rooms });
});
it('joinRoom', () => {
const roomInfo = makeRoom({ roomId: 2 });
expect(Actions.joinRoom(roomInfo)).toEqual({ type: Types.JOIN_ROOM, roomInfo });
});
it('leaveRoom', () => {
expect(Actions.leaveRoom(3)).toEqual({ type: Types.LEAVE_ROOM, roomId: 3 });
});
it('addMessage', () => {
const message = makeMessage();
expect(Actions.addMessage(1, message)).toEqual({ type: Types.ADD_MESSAGE, roomId: 1, message });
});
it('updateGames', () => {
const games = [makeGame()];
expect(Actions.updateGames(1, games)).toEqual({ type: Types.UPDATE_GAMES, roomId: 1, games });
});
it('userJoined', () => {
const user = makeUser();
expect(Actions.userJoined(1, user)).toEqual({ type: Types.USER_JOINED, roomId: 1, user });
});
it('userLeft', () => {
expect(Actions.userLeft(1, 'Alice')).toEqual({ type: Types.USER_LEFT, roomId: 1, name: 'Alice' });
});
it('sortGames', () => {
expect(Actions.sortGames(1, GameSortField.START_TIME, SortDirection.ASC)).toEqual({
type: Types.SORT_GAMES,
roomId: 1,
field: GameSortField.START_TIME,
order: SortDirection.ASC,
});
});
it('removeMessages', () => {
expect(Actions.removeMessages(1, 'Alice', 3)).toEqual({
type: Types.REMOVE_MESSAGES,
roomId: 1,
name: 'Alice',
amount: 3,
});
});
it('gameCreated', () => {
expect(Actions.gameCreated(2)).toEqual({ type: Types.GAME_CREATED, roomId: 2 });
});
it('joinedGame', () => {
expect(Actions.joinedGame(1, 5)).toEqual({ type: Types.JOINED_GAME, roomId: 1, gameId: 5 });
});
});

View file

@ -0,0 +1,90 @@
jest.mock('store/store', () => ({ store: { dispatch: jest.fn() } }));
jest.mock('redux-form', () => ({
reset: jest.fn((form) => ({ type: '@@redux-form/RESET', meta: { form } })),
}));
import { store } from 'store/store';
import { reset } from 'redux-form';
import { Actions } from './rooms.actions';
import { Dispatch } from './rooms.dispatch';
import { makeGame, makeMessage, makeRoom, makeUser } from './__mocks__/rooms-fixtures';
import { GameSortField, SortDirection } from 'types';
beforeEach(() => jest.clearAllMocks());
describe('Dispatch', () => {
it('clearStore dispatches Actions.clearStore()', () => {
Dispatch.clearStore();
expect(store.dispatch).toHaveBeenCalledWith(Actions.clearStore());
});
it('updateRooms dispatches Actions.updateRooms()', () => {
const rooms = [makeRoom()];
Dispatch.updateRooms(rooms);
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateRooms(rooms));
});
it('joinRoom dispatches Actions.joinRoom()', () => {
const roomInfo = makeRoom({ roomId: 2 });
Dispatch.joinRoom(roomInfo);
expect(store.dispatch).toHaveBeenCalledWith(Actions.joinRoom(roomInfo));
});
it('leaveRoom dispatches Actions.leaveRoom()', () => {
Dispatch.leaveRoom(3);
expect(store.dispatch).toHaveBeenCalledWith(Actions.leaveRoom(3));
});
it('addMessage with message.name falsy → dispatches only Actions.addMessage()', () => {
const message = { ...makeMessage(), name: undefined };
Dispatch.addMessage(1, message);
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith(Actions.addMessage(1, message));
});
it('addMessage with message.name truthy → dispatches reset("sayMessage") then Actions.addMessage()', () => {
const message = { ...makeMessage(), name: 'Alice' };
Dispatch.addMessage(1, message);
expect(store.dispatch).toHaveBeenNthCalledWith(1, (reset as jest.Mock)('sayMessage'));
expect(store.dispatch).toHaveBeenNthCalledWith(2, Actions.addMessage(1, message));
});
it('updateGames dispatches Actions.updateGames()', () => {
const games = [makeGame()];
Dispatch.updateGames(1, games);
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateGames(1, games));
});
it('userJoined dispatches Actions.userJoined()', () => {
const user = makeUser();
Dispatch.userJoined(1, user);
expect(store.dispatch).toHaveBeenCalledWith(Actions.userJoined(1, user));
});
it('userLeft dispatches Actions.userLeft()', () => {
Dispatch.userLeft(1, 'Alice');
expect(store.dispatch).toHaveBeenCalledWith(Actions.userLeft(1, 'Alice'));
});
it('sortGames dispatches Actions.sortGames()', () => {
Dispatch.sortGames(1, GameSortField.START_TIME, SortDirection.ASC);
expect(store.dispatch).toHaveBeenCalledWith(
Actions.sortGames(1, GameSortField.START_TIME, SortDirection.ASC)
);
});
it('removeMessages dispatches Actions.removeMessages()', () => {
Dispatch.removeMessages(1, 'Alice', 5);
expect(store.dispatch).toHaveBeenCalledWith(Actions.removeMessages(1, 'Alice', 5));
});
it('gameCreated dispatches Actions.gameCreated()', () => {
Dispatch.gameCreated(2);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameCreated(2));
});
it('joinedGame dispatches Actions.joinedGame()', () => {
Dispatch.joinedGame(1, 5);
expect(store.dispatch).toHaveBeenCalledWith(Actions.joinedGame(1, 5));
});
});

View file

@ -0,0 +1,285 @@
import { GameSortField, SortDirection } from 'types';
import { roomsReducer } from './rooms.reducer';
import { Types, MAX_ROOM_MESSAGES } from './rooms.types';
import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures';
// ── Initialisation ───────────────────────────────────────────────────────────
describe('Initialisation', () => {
it('returns initialState when called with undefined state', () => {
const result = roomsReducer(undefined, { type: '@@INIT' });
expect(result.rooms).toEqual({});
expect(result.joinedRoomIds).toEqual({});
});
it('CLEAR_STORE → resets to initialState', () => {
const state = makeRoomsState({ joinedRoomIds: { 1: true } });
const result = roomsReducer(state, { type: Types.CLEAR_STORE });
expect(result.joinedRoomIds).toEqual({});
expect(result.rooms).toEqual({});
});
it('default → returns state unchanged for unknown action', () => {
const state = makeRoomsState();
const result = roomsReducer(state, { type: '@@UNKNOWN' });
expect(result).toBe(state);
});
});
// ── UPDATE_ROOMS ──────────────────────────────────────────────────────────────
describe('UPDATE_ROOMS', () => {
it('merges rooms and strips gameList, gametypeList, userList from update', () => {
const state = makeRoomsState({ rooms: {} });
const room = { ...makeRoom({ roomId: 1 }), gameList: [makeGame()], userList: [makeUser()], gametypeList: ['standard'] };
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms: [room] });
expect(result.rooms[1]).toBeDefined();
expect(result.rooms[1].gameList).toBeUndefined();
expect(result.rooms[1].userList).toBeUndefined();
expect(result.rooms[1].gametypeList).toBeUndefined();
});
it('sets numeric order from array index', () => {
const state = makeRoomsState({ rooms: {} });
const rooms = [makeRoom({ roomId: 1 }), makeRoom({ roomId: 2 })];
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms });
expect(result.rooms[1].order).toBe(0);
expect(result.rooms[2].order).toBe(1);
});
it('merges into existing room entry (preserves existing fields)', () => {
const existingRoom = makeRoom({ roomId: 1, name: 'Old Name', gameList: [makeGame()] });
const state = makeRoomsState({ rooms: { 1: existingRoom } });
const update = makeRoom({ roomId: 1, name: 'New Name' });
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms: [update] });
expect(result.rooms[1].name).toBe('New Name');
expect(result.rooms[1].gameList).toEqual([makeGame()]);
});
it('creates new room entry for unknown roomId', () => {
const state = makeRoomsState({ rooms: {} });
const room = makeRoom({ roomId: 99, name: 'New Room' });
const result = roomsReducer(state, { type: Types.UPDATE_ROOMS, rooms: [room] });
expect(result.rooms[99]).toBeDefined();
expect(result.rooms[99].name).toBe('New Room');
});
});
// ── JOIN_ROOM ──────────────────────────────────────────────────────────────────
describe('JOIN_ROOM', () => {
it('copies gameList and userList, sorts both, sets joinedRoomIds', () => {
const state = makeRoomsState({ rooms: {}, joinedRoomIds: {} });
const roomInfo = makeRoom({
roomId: 2,
gameList: [makeGame({ gameId: 1 })],
userList: [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })],
});
const result = roomsReducer(state, { type: Types.JOIN_ROOM, roomInfo });
expect(result.joinedRoomIds[2]).toBe(true);
expect(result.rooms[2].userList[0].name).toBe('Alice');
expect(result.rooms[2]).toMatchObject({ roomId: 2 });
});
});
// ── LEAVE_ROOM ────────────────────────────────────────────────────────────────
describe('LEAVE_ROOM', () => {
it('removes joinedRoomIds entry and messages for roomId', () => {
const state = makeRoomsState({
joinedRoomIds: { 1: true },
messages: { 1: [makeMessage()] },
});
const result = roomsReducer(state, { type: Types.LEAVE_ROOM, roomId: 1 });
expect(result.joinedRoomIds[1]).toBeUndefined();
expect(result.messages[1]).toBeUndefined();
});
});
// ── ADD_MESSAGE ───────────────────────────────────────────────────────────────
describe('ADD_MESSAGE', () => {
it('appends message with timeReceived set', () => {
const state = makeRoomsState({ messages: { 1: [] } });
const message = makeMessage({ message: 'hello', timeReceived: 0 });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message });
expect(result.messages[1]).toHaveLength(1);
expect(result.messages[1][0].timeReceived).toBeGreaterThan(0);
});
it('creates message list for roomId when none exists', () => {
const state = makeRoomsState({ messages: {} });
const message = makeMessage();
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 5, message });
expect(result.messages[5]).toHaveLength(1);
});
it(`shifts oldest message when list is at MAX_ROOM_MESSAGES (${MAX_ROOM_MESSAGES})`, () => {
const firstMsg = makeMessage({ message: 'first' });
const messages = Array.from({ length: MAX_ROOM_MESSAGES }, (_, i) =>
i === 0 ? firstMsg : makeMessage({ message: `msg-${i}` })
);
const state = makeRoomsState({ messages: { 1: messages } });
const newMsg = makeMessage({ message: 'new' });
const result = roomsReducer(state, { type: Types.ADD_MESSAGE, roomId: 1, message: newMsg });
expect(result.messages[1]).toHaveLength(MAX_ROOM_MESSAGES);
expect(result.messages[1][0].message).not.toBe('first');
expect(result.messages[1][MAX_ROOM_MESSAGES - 1].message).toBe('new');
});
});
// ── UPDATE_GAMES ──────────────────────────────────────────────────────────────
describe('UPDATE_GAMES', () => {
it('removes closed games from gameList', () => {
const room = makeRoom({ roomId: 1, gameList: [makeGame({ gameId: 1 })] });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.UPDATE_GAMES,
roomId: 1,
games: [{ gameId: 1, closed: true }],
});
expect(result.rooms[1].gameList).toHaveLength(0);
});
it('merges update into existing game', () => {
const game = makeGame({ gameId: 1, description: 'old' });
const room = makeRoom({ roomId: 1, gameList: [game] });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.UPDATE_GAMES,
roomId: 1,
games: [{ gameId: 1, description: 'new' }],
});
expect(result.rooms[1].gameList[0].description).toBe('new');
});
it('appends new game to list and sorts', () => {
const room = makeRoom({ roomId: 1, gameList: [] });
const state = makeRoomsState({ rooms: { 1: room } });
const newGame = makeGame({ gameId: 99, description: 'extra' });
const result = roomsReducer(state, { type: Types.UPDATE_GAMES, roomId: 1, games: [newGame] });
expect(result.rooms[1].gameList).toHaveLength(1);
expect(result.rooms[1].gameList[0].gameId).toBe(99);
});
it('preserves existing games not included in the update', () => {
const game1 = makeGame({ gameId: 1, description: 'untouched' });
const game2 = makeGame({ gameId: 2, description: 'old' });
const room = makeRoom({ roomId: 1, gameList: [game1, game2] });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.UPDATE_GAMES,
roomId: 1,
games: [{ gameId: 2, description: 'new' }],
});
expect(result.rooms[1].gameList.find(g => g.gameId === 1).description).toBe('untouched');
expect(result.rooms[1].gameList.find(g => g.gameId === 2).description).toBe('new');
});
it('returns { ...state } (not identity) when roomId is unknown', () => {
const state = makeRoomsState({ rooms: {} });
const result = roomsReducer(state, { type: Types.UPDATE_GAMES, roomId: 999, games: [] });
expect(result).not.toBe(state);
expect(result.rooms).toEqual(state.rooms);
});
});
// ── USER_JOINED / USER_LEFT ───────────────────────────────────────────────────
describe('USER_JOINED', () => {
it('appends user to userList and sorts by name ASC', () => {
const room = makeRoom({ roomId: 1, userList: [makeUser({ name: 'Zane' })] });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, { type: Types.USER_JOINED, roomId: 1, user: makeUser({ name: 'Alice' }) });
expect(result.rooms[1].userList[0].name).toBe('Alice');
expect(result.rooms[1].userList).toHaveLength(2);
});
});
describe('USER_LEFT', () => {
it('removes user by name from userList', () => {
const room = makeRoom({ roomId: 1, userList: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, { type: Types.USER_LEFT, roomId: 1, name: 'Alice' });
expect(result.rooms[1].userList).toHaveLength(1);
expect(result.rooms[1].userList[0].name).toBe('Bob');
});
});
// ── SORT_GAMES ────────────────────────────────────────────────────────────────
describe('SORT_GAMES', () => {
it('resorts gameList and updates sortGamesBy on state', () => {
const games = [makeGame({ gameId: 2 }), makeGame({ gameId: 1 })];
const room = makeRoom({ roomId: 1, gameList: games });
const state = makeRoomsState({ rooms: { 1: room } });
const result = roomsReducer(state, {
type: Types.SORT_GAMES,
roomId: 1,
field: GameSortField.START_TIME,
order: SortDirection.ASC,
});
expect(result.sortGamesBy).toEqual({ field: GameSortField.START_TIME, order: SortDirection.ASC });
});
});
// ── REMOVE_MESSAGES ───────────────────────────────────────────────────────────
describe('REMOVE_MESSAGES', () => {
it('removes messages starting with "name:" up to amount, in reverse scan order', () => {
const msgs = [
makeMessage({ message: 'Alice: hello' }),
makeMessage({ message: 'Bob: hi' }),
makeMessage({ message: 'Alice: world' }),
];
const state = makeRoomsState({ messages: { 1: msgs } });
const result = roomsReducer(state, { type: Types.REMOVE_MESSAGES, roomId: 1, name: 'Alice', amount: 1 });
// reverse scan: removes LAST 'Alice:' message first, stops after 1
const remaining = result.messages[1];
expect(remaining).toHaveLength(2);
const aliceMessages = remaining.filter(m => m.message.startsWith('Alice:'));
expect(aliceMessages).toHaveLength(1);
expect(aliceMessages[0].message).toBe('Alice: hello');
});
it('removes up to amount matching messages', () => {
const msgs = [
makeMessage({ message: 'Alice: one' }),
makeMessage({ message: 'Alice: two' }),
makeMessage({ message: 'Alice: three' }),
];
const state = makeRoomsState({ messages: { 1: msgs } });
const result = roomsReducer(state, { type: Types.REMOVE_MESSAGES, roomId: 1, name: 'Alice', amount: 2 });
const remaining = result.messages[1];
expect(remaining).toHaveLength(1);
});
it('stops removing once amount is reached', () => {
const msgs = [
makeMessage({ message: 'Alice: a' }),
makeMessage({ message: 'Alice: b' }),
makeMessage({ message: 'Alice: c' }),
];
const state = makeRoomsState({ messages: { 1: msgs } });
const result = roomsReducer(state, { type: Types.REMOVE_MESSAGES, roomId: 1, name: 'Alice', amount: 1 });
expect(result.messages[1]).toHaveLength(2);
});
});
// ── JOINED_GAME ───────────────────────────────────────────────────────────────
describe('JOINED_GAME', () => {
it('sets joinedGameIds[roomId][gameId] = true', () => {
const state = makeRoomsState({ joinedGameIds: {} });
const result = roomsReducer(state, { type: Types.JOINED_GAME, roomId: 1, gameId: 5 });
expect(result.joinedGameIds[1][5]).toBe(true);
});
it('preserves other roomId entries in joinedGameIds', () => {
const state = makeRoomsState({ joinedGameIds: { 2: { 9: true } } });
const result = roomsReducer(state, { type: Types.JOINED_GAME, roomId: 1, gameId: 5 });
expect(result.joinedGameIds[2][9]).toBe(true);
expect(result.joinedGameIds[1][5]).toBe(true);
});
});

View file

@ -0,0 +1,107 @@
import { Selectors } from './rooms.selectors';
import { RoomsState } from './rooms.interfaces';
import { makeGame, makeMessage, makeRoom, makeRoomsState, makeUser } from './__mocks__/rooms-fixtures';
function rootState(rooms: RoomsState) {
return { rooms };
}
describe('Selectors', () => {
it('getRooms → returns rooms map', () => {
const state = makeRoomsState();
expect(Selectors.getRooms(rootState(state))).toBe(state.rooms);
});
it('getGames → returns games map', () => {
const state = makeRoomsState({ games: { 1: { 1: makeGame() } } });
expect(Selectors.getGames(rootState(state))).toBe(state.games);
});
it('getRoom → returns room matching roomId', () => {
const room = makeRoom({ roomId: 1 });
const state = makeRoomsState({ rooms: { 1: room } });
expect(Selectors.getRoom(rootState(state), 1)).toBe(room);
});
it('getRoom → returns undefined for unknown roomId', () => {
const state = makeRoomsState({ rooms: {} });
expect(Selectors.getRoom(rootState(state), 999)).toBeUndefined();
});
it('getJoinedRoomIds → returns joinedRoomIds', () => {
const joinedRoomIds = { 1: true };
const state = makeRoomsState({ joinedRoomIds });
expect(Selectors.getJoinedRoomIds(rootState(state))).toBe(joinedRoomIds);
});
it('getJoinedGameIds → returns joinedGameIds', () => {
const joinedGameIds = { 1: { 5: true } };
const state = makeRoomsState({ joinedGameIds });
expect(Selectors.getJoinedGameIds(rootState(state))).toBe(joinedGameIds);
});
it('getMessages → returns messages map', () => {
const messages = { 1: [makeMessage()] };
const state = makeRoomsState({ messages });
expect(Selectors.getMessages(rootState(state))).toBe(messages);
});
it('getSortGamesBy → returns sortGamesBy', () => {
const state = makeRoomsState();
expect(Selectors.getSortGamesBy(rootState(state))).toBe(state.sortGamesBy);
});
it('getSortUsersBy → returns sortUsersBy', () => {
const state = makeRoomsState();
expect(Selectors.getSortUsersBy(rootState(state))).toBe(state.sortUsersBy);
});
it('getJoinedRooms → returns only rooms whose roomId is in joinedRoomIds', () => {
const room1 = makeRoom({ roomId: 1 });
const room2 = makeRoom({ roomId: 2 });
const state = makeRoomsState({
rooms: { 1: room1, 2: room2 },
joinedRoomIds: { 1: true },
});
const result = Selectors.getJoinedRooms(rootState(state));
expect(result).toHaveLength(1);
expect(result[0]).toBe(room1);
});
it('getJoinedRooms → returns empty array when none joined', () => {
const state = makeRoomsState({ rooms: { 1: makeRoom({ roomId: 1 }) }, joinedRoomIds: {} });
expect(Selectors.getJoinedRooms(rootState(state))).toHaveLength(0);
});
it('getJoinedGames → returns only games whose gameId is in joinedGameIds for that room', () => {
const game1 = makeGame({ gameId: 1 });
const game2 = makeGame({ gameId: 2 });
const state = makeRoomsState({
games: { 1: { 1: game1, 2: game2 } },
joinedGameIds: { 1: { 1: true } },
});
const result = Selectors.getJoinedGames(rootState(state), 1);
expect(result).toHaveLength(1);
expect(result[0]).toBe(game1);
});
it('getRoomMessages → returns messages array for roomId', () => {
const messages = [makeMessage()];
const state = makeRoomsState({ messages: { 1: messages } });
expect(Selectors.getRoomMessages(rootState(state), 1)).toBe(messages);
});
it('getRoomGames → returns gameList for roomId', () => {
const games = [makeGame()];
const room = makeRoom({ roomId: 1, gameList: games });
const state = makeRoomsState({ rooms: { 1: room } });
expect(Selectors.getRoomGames(rootState(state), 1)).toBe(games);
});
it('getRoomUsers → returns userList for roomId', () => {
const users = [makeUser()];
const room = makeRoom({ roomId: 1, userList: users });
const state = makeRoomsState({ rooms: { 1: room } });
expect(Selectors.getRoomUsers(rootState(state), 1)).toBe(users);
});
});

View file

@ -0,0 +1,154 @@
import {
BanHistoryItem,
DeckList,
DeckStorageTreeItem,
LogItem,
ReplayMatch,
SortDirection,
StatusEnum,
User,
UserPrivLevel,
UserSortField,
WebSocketConnectOptions,
WarnHistoryItem,
WarnListItem,
} from 'types';
import { ServerState } from '../server.interfaces';
export function makeUser(overrides: Partial<User> = {}): User {
return {
name: 'TestUser',
accountageSecs: 0,
privlevel: UserPrivLevel.NONE,
userLevel: 0,
...overrides,
};
}
export function makeLogItem(overrides: Partial<LogItem> = {}): LogItem {
return {
message: '',
senderId: '',
senderIp: '',
senderName: '',
targetId: '',
targetName: '',
targetType: '',
time: '',
...overrides,
};
}
export function makeBanHistoryItem(overrides: Partial<BanHistoryItem> = {}): BanHistoryItem {
return {
adminId: '',
adminName: '',
banTime: '',
banLength: '',
banReason: '',
visibleReason: '',
...overrides,
};
}
export function makeWarnHistoryItem(overrides: Partial<WarnHistoryItem> = {}): WarnHistoryItem {
return {
userName: '',
adminName: '',
reason: '',
timeOf: '',
...overrides,
};
}
export function makeWarnListItem(overrides: Partial<WarnListItem> = {}): WarnListItem {
return {
warning: '',
userName: '',
userClientid: '',
...overrides,
};
}
export function makeDeckTreeItem(overrides: Partial<DeckStorageTreeItem> = {}): DeckStorageTreeItem {
return {
id: 1,
name: 'item',
file: { creationTime: 0 },
folder: null,
...overrides,
};
}
export function makeDeckList(overrides: Partial<DeckList> = {}): DeckList {
return {
root: { items: [] },
...overrides,
};
}
export function makeReplayMatch(overrides: Partial<ReplayMatch> = {}): ReplayMatch {
return {
gameId: 1,
roomName: 'Test Room',
timeStarted: 0,
length: 0,
gameName: 'Test Game',
playerNames: [],
doNotHide: false,
replayList: [],
...overrides,
};
}
export function makeConnectOptions(overrides: Partial<WebSocketConnectOptions> = {}): WebSocketConnectOptions {
return {
host: 'localhost',
port: '4747',
userName: 'user',
password: 'pass',
...overrides,
};
}
export function makeServerState(overrides: Partial<ServerState> = {}): ServerState {
return {
initialized: false,
buddyList: [],
ignoreList: [],
status: {
state: StatusEnum.DISCONNECTED,
description: null,
},
info: {
message: null,
name: null,
version: null,
},
logs: {
room: [],
game: [],
chat: [],
},
user: null,
users: [],
sortUsersBy: {
field: UserSortField.NAME,
order: SortDirection.ASC,
},
connectOptions: {},
messages: {},
userInfo: {},
notifications: [],
serverShutdown: null,
banUser: '',
banHistory: {},
warnHistory: {},
warnListOptions: [],
warnUser: '',
adminNotes: {},
replays: [],
backendDecks: null,
...overrides,
};
}

View file

@ -0,0 +1,356 @@
import { Actions } from './server.actions';
import { Types } from './server.types';
import {
makeBanHistoryItem,
makeConnectOptions,
makeDeckList,
makeDeckTreeItem,
makeReplayMatch,
makeUser,
makeWarnHistoryItem,
makeWarnListItem,
} from './__mocks__/server-fixtures';
describe('Actions', () => {
it('initialized', () => {
expect(Actions.initialized()).toEqual({ type: Types.INITIALIZED });
});
it('clearStore', () => {
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE });
});
it('loginSuccessful', () => {
const options = makeConnectOptions();
expect(Actions.loginSuccessful(options)).toEqual({ type: Types.LOGIN_SUCCESSFUL, options });
});
it('loginFailed', () => {
expect(Actions.loginFailed()).toEqual({ type: Types.LOGIN_FAILED });
});
it('connectionClosed', () => {
expect(Actions.connectionClosed(3)).toEqual({ type: Types.CONNECTION_CLOSED, reason: 3 });
});
it('connectionFailed', () => {
expect(Actions.connectionFailed()).toEqual({ type: Types.CONNECTION_FAILED });
});
it('testConnectionSuccessful', () => {
expect(Actions.testConnectionSuccessful()).toEqual({ type: Types.TEST_CONNECTION_SUCCESSFUL });
});
it('testConnectionFailed', () => {
expect(Actions.testConnectionFailed()).toEqual({ type: Types.TEST_CONNECTION_FAILED });
});
it('serverMessage', () => {
expect(Actions.serverMessage('hello')).toEqual({ type: Types.SERVER_MESSAGE, message: 'hello' });
});
it('updateBuddyList', () => {
const list = [makeUser()];
expect(Actions.updateBuddyList(list)).toEqual({ type: Types.UPDATE_BUDDY_LIST, buddyList: list });
});
it('addToBuddyList', () => {
const user = makeUser();
expect(Actions.addToBuddyList(user)).toEqual({ type: Types.ADD_TO_BUDDY_LIST, user });
});
it('removeFromBuddyList', () => {
expect(Actions.removeFromBuddyList('Alice')).toEqual({ type: Types.REMOVE_FROM_BUDDY_LIST, userName: 'Alice' });
});
it('updateIgnoreList', () => {
const list = [makeUser()];
expect(Actions.updateIgnoreList(list)).toEqual({ type: Types.UPDATE_IGNORE_LIST, ignoreList: list });
});
it('addToIgnoreList', () => {
const user = makeUser();
expect(Actions.addToIgnoreList(user)).toEqual({ type: Types.ADD_TO_IGNORE_LIST, user });
});
it('removeFromIgnoreList', () => {
expect(Actions.removeFromIgnoreList('Bob')).toEqual({ type: Types.REMOVE_FROM_IGNORE_LIST, userName: 'Bob' });
});
it('updateInfo', () => {
const info = { name: 'Servatrice', version: '2.0' };
expect(Actions.updateInfo(info)).toEqual({ type: Types.UPDATE_INFO, info });
});
it('updateStatus', () => {
const status = { state: 2, description: 'connected' };
expect(Actions.updateStatus(status)).toEqual({ type: Types.UPDATE_STATUS, status });
});
it('updateUser', () => {
const user = makeUser();
expect(Actions.updateUser(user)).toEqual({ type: Types.UPDATE_USER, user });
});
it('updateUsers', () => {
const users = [makeUser()];
expect(Actions.updateUsers(users)).toEqual({ type: Types.UPDATE_USERS, users });
});
it('userJoined', () => {
const user = makeUser();
expect(Actions.userJoined(user)).toEqual({ type: Types.USER_JOINED, user });
});
it('userLeft', () => {
expect(Actions.userLeft('Carol')).toEqual({ type: Types.USER_LEFT, name: 'Carol' });
});
it('viewLogs', () => {
const logs = { room: [], game: [], chat: [] };
expect(Actions.viewLogs(logs)).toEqual({ type: Types.VIEW_LOGS, logs });
});
it('clearLogs', () => {
expect(Actions.clearLogs()).toEqual({ type: Types.CLEAR_LOGS });
});
it('registrationRequiresEmail', () => {
expect(Actions.registrationRequiresEmail()).toEqual({ type: Types.REGISTRATION_REQUIRES_EMAIL });
});
it('registrationSuccess', () => {
expect(Actions.registrationSuccess()).toEqual({ type: Types.REGISTRATION_SUCCES });
});
it('registrationFailed', () => {
expect(Actions.registrationFailed('err')).toEqual({ type: Types.REGISTRATION_FAILED, error: 'err' });
});
it('registrationEmailError', () => {
expect(Actions.registrationEmailError('bad email')).toEqual({ type: Types.REGISTRATION_EMAIL_ERROR, error: 'bad email' });
});
it('registrationPasswordError', () => {
expect(Actions.registrationPasswordError('bad pw')).toEqual({ type: Types.REGISTRATION_PASSWORD_ERROR, error: 'bad pw' });
});
it('registrationUserNameError', () => {
expect(Actions.registrationUserNameError('bad name')).toEqual({ type: Types.REGISTRATION_USERNAME_ERROR, error: 'bad name' });
});
it('accountAwaitingActivation', () => {
const options = makeConnectOptions();
expect(Actions.accountAwaitingActivation(options)).toEqual({ type: Types.ACCOUNT_AWAITING_ACTIVATION, options });
});
it('accountActivationSuccess', () => {
expect(Actions.accountActivationSuccess()).toEqual({ type: Types.ACCOUNT_ACTIVATION_SUCCESS });
});
it('accountActivationFailed', () => {
expect(Actions.accountActivationFailed()).toEqual({ type: Types.ACCOUNT_ACTIVATION_FAILED });
});
it('resetPassword', () => {
expect(Actions.resetPassword()).toEqual({ type: Types.RESET_PASSWORD_REQUESTED });
});
it('resetPasswordFailed', () => {
expect(Actions.resetPasswordFailed()).toEqual({ type: Types.RESET_PASSWORD_FAILED });
});
it('resetPasswordChallenge', () => {
expect(Actions.resetPasswordChallenge()).toEqual({ type: Types.RESET_PASSWORD_CHALLENGE });
});
it('resetPasswordSuccess', () => {
expect(Actions.resetPasswordSuccess()).toEqual({ type: Types.RESET_PASSWORD_SUCCESS });
});
it('adjustMod', () => {
expect(Actions.adjustMod('Dan', true, false)).toEqual({
type: Types.ADJUST_MOD,
userName: 'Dan',
shouldBeMod: true,
shouldBeJudge: false,
});
});
it('reloadConfig', () => {
expect(Actions.reloadConfig()).toEqual({ type: Types.RELOAD_CONFIG });
});
it('shutdownServer', () => {
expect(Actions.shutdownServer()).toEqual({ type: Types.SHUTDOWN_SERVER });
});
it('updateServerMessage', () => {
expect(Actions.updateServerMessage()).toEqual({ type: Types.UPDATE_SERVER_MESSAGE });
});
it('accountPasswordChange', () => {
expect(Actions.accountPasswordChange()).toEqual({ type: Types.ACCOUNT_PASSWORD_CHANGE });
});
it('accountEditChanged', () => {
const user = makeUser();
expect(Actions.accountEditChanged(user)).toEqual({ type: Types.ACCOUNT_EDIT_CHANGED, user });
});
it('accountImageChanged', () => {
const user = makeUser();
expect(Actions.accountImageChanged(user)).toEqual({ type: Types.ACCOUNT_IMAGE_CHANGED, user });
});
it('directMessageSent', () => {
expect(Actions.directMessageSent('Eve', 'hi')).toEqual({
type: Types.DIRECT_MESSAGE_SENT,
userName: 'Eve',
message: 'hi',
});
});
it('getUserInfo', () => {
const userInfo = makeUser({ name: 'Frank' });
expect(Actions.getUserInfo(userInfo)).toEqual({ type: Types.GET_USER_INFO, userInfo });
});
it('notifyUser', () => {
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' };
expect(Actions.notifyUser(notification)).toEqual({ type: Types.NOTIFY_USER, notification });
});
it('serverShutdown', () => {
const data = { reason: 'maintenance', minutes: 5 };
expect(Actions.serverShutdown(data)).toEqual({ type: Types.SERVER_SHUTDOWN, data });
});
it('userMessage', () => {
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hey' };
expect(Actions.userMessage(messageData)).toEqual({ type: Types.USER_MESSAGE, messageData });
});
it('addToList', () => {
expect(Actions.addToList('buddyList', 'Grace')).toEqual({
type: Types.ADD_TO_LIST,
list: 'buddyList',
userName: 'Grace',
});
});
it('removeFromList', () => {
expect(Actions.removeFromList('buddyList', 'Hank')).toEqual({
type: Types.REMOVE_FROM_LIST,
list: 'buddyList',
userName: 'Hank',
});
});
it('banFromServer', () => {
expect(Actions.banFromServer('Ira')).toEqual({ type: Types.BAN_FROM_SERVER, userName: 'Ira' });
});
it('banHistory', () => {
const history = [makeBanHistoryItem()];
expect(Actions.banHistory('Ira', history)).toEqual({ type: Types.BAN_HISTORY, userName: 'Ira', banHistory: history });
});
it('warnHistory', () => {
const history = [makeWarnHistoryItem()];
expect(Actions.warnHistory('Jack', history)).toEqual({ type: Types.WARN_HISTORY, userName: 'Jack', warnHistory: history });
});
it('warnListOptions', () => {
const list = [makeWarnListItem()];
expect(Actions.warnListOptions(list)).toEqual({ type: Types.WARN_LIST_OPTIONS, warnList: list });
});
it('warnUser', () => {
expect(Actions.warnUser('Kelly')).toEqual({ type: Types.WARN_USER, userName: 'Kelly' });
});
it('grantReplayAccess', () => {
expect(Actions.grantReplayAccess(7, 'Moe')).toEqual({
type: Types.GRANT_REPLAY_ACCESS,
replayId: 7,
moderatorName: 'Moe',
});
});
it('forceActivateUser', () => {
expect(Actions.forceActivateUser('Ned', 'Moe')).toEqual({
type: Types.FORCE_ACTIVATE_USER,
usernameToActivate: 'Ned',
moderatorName: 'Moe',
});
});
it('getAdminNotes', () => {
expect(Actions.getAdminNotes('Ned', 'some notes')).toEqual({
type: Types.GET_ADMIN_NOTES,
userName: 'Ned',
notes: 'some notes',
});
});
it('updateAdminNotes', () => {
expect(Actions.updateAdminNotes('Ned', 'updated notes')).toEqual({
type: Types.UPDATE_ADMIN_NOTES,
userName: 'Ned',
notes: 'updated notes',
});
});
it('replayList', () => {
const list = [makeReplayMatch()];
expect(Actions.replayList(list)).toEqual({ type: Types.REPLAY_LIST, matchList: list });
});
it('replayAdded', () => {
const match = makeReplayMatch();
expect(Actions.replayAdded(match)).toEqual({ type: Types.REPLAY_ADDED, matchInfo: match });
});
it('replayModifyMatch', () => {
expect(Actions.replayModifyMatch(5, true)).toEqual({
type: Types.REPLAY_MODIFY_MATCH,
gameId: 5,
doNotHide: true,
});
});
it('replayDeleteMatch', () => {
expect(Actions.replayDeleteMatch(5)).toEqual({ type: Types.REPLAY_DELETE_MATCH, gameId: 5 });
});
it('backendDecks', () => {
const deckList = makeDeckList();
expect(Actions.backendDecks(deckList)).toEqual({ type: Types.BACKEND_DECKS, deckList });
});
it('deckNewDir', () => {
expect(Actions.deckNewDir('a/b', 'newFolder')).toEqual({
type: Types.DECK_NEW_DIR,
path: 'a/b',
dirName: 'newFolder',
});
});
it('deckDelDir', () => {
expect(Actions.deckDelDir('a/b')).toEqual({ type: Types.DECK_DEL_DIR, path: 'a/b' });
});
it('deckUpload', () => {
const treeItem = makeDeckTreeItem();
expect(Actions.deckUpload('a/b', treeItem)).toEqual({
type: Types.DECK_UPLOAD,
path: 'a/b',
treeItem,
});
});
it('deckDelete', () => {
expect(Actions.deckDelete(42)).toEqual({ type: Types.DECK_DELETE, deckId: 42 });
});
});

View file

@ -0,0 +1,388 @@
jest.mock('store/store', () => ({ store: { dispatch: jest.fn() } }));
jest.mock('redux-form', () => ({
reset: jest.fn((form) => ({ type: '@@redux-form/RESET', meta: { form } })),
}));
import { store } from 'store/store';
import { reset } from 'redux-form';
import { Actions } from './server.actions';
import { Dispatch } from './server.dispatch';
import {
makeBanHistoryItem,
makeConnectOptions,
makeDeckList,
makeDeckTreeItem,
makeReplayMatch,
makeUser,
makeWarnHistoryItem,
makeWarnListItem,
} from './__mocks__/server-fixtures';
beforeEach(() => jest.clearAllMocks());
describe('Dispatch', () => {
it('initialized dispatches Actions.initialized()', () => {
Dispatch.initialized();
expect(store.dispatch).toHaveBeenCalledWith(Actions.initialized());
});
it('clearStore dispatches Actions.clearStore()', () => {
Dispatch.clearStore();
expect(store.dispatch).toHaveBeenCalledWith(Actions.clearStore());
});
it('loginSuccessful dispatches Actions.loginSuccessful()', () => {
const options = makeConnectOptions();
Dispatch.loginSuccessful(options);
expect(store.dispatch).toHaveBeenCalledWith(Actions.loginSuccessful(options));
});
it('loginFailed dispatches Actions.loginFailed()', () => {
Dispatch.loginFailed();
expect(store.dispatch).toHaveBeenCalledWith(Actions.loginFailed());
});
it('connectionClosed dispatches Actions.connectionClosed()', () => {
Dispatch.connectionClosed(3);
expect(store.dispatch).toHaveBeenCalledWith(Actions.connectionClosed(3));
});
it('connectionFailed dispatches Actions.connectionFailed()', () => {
Dispatch.connectionFailed();
expect(store.dispatch).toHaveBeenCalledWith(Actions.connectionFailed());
});
it('testConnectionSuccessful dispatches Actions.testConnectionSuccessful()', () => {
Dispatch.testConnectionSuccessful();
expect(store.dispatch).toHaveBeenCalledWith(Actions.testConnectionSuccessful());
});
it('testConnectionFailed dispatches Actions.testConnectionFailed()', () => {
Dispatch.testConnectionFailed();
expect(store.dispatch).toHaveBeenCalledWith(Actions.testConnectionFailed());
});
it('updateBuddyList dispatches Actions.updateBuddyList()', () => {
const list = [makeUser()];
Dispatch.updateBuddyList(list);
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateBuddyList(list));
});
it('addToBuddyList dispatches reset("addToBuddies") then Actions.addToBuddyList()', () => {
const user = makeUser();
Dispatch.addToBuddyList(user);
expect(store.dispatch).toHaveBeenNthCalledWith(1, (reset as jest.Mock)('addToBuddies'));
expect(store.dispatch).toHaveBeenNthCalledWith(2, Actions.addToBuddyList(user));
});
it('removeFromBuddyList dispatches Actions.removeFromBuddyList()', () => {
Dispatch.removeFromBuddyList('Alice');
expect(store.dispatch).toHaveBeenCalledWith(Actions.removeFromBuddyList('Alice'));
});
it('updateIgnoreList dispatches Actions.updateIgnoreList()', () => {
const list = [makeUser()];
Dispatch.updateIgnoreList(list);
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateIgnoreList(list));
});
it('addToIgnoreList dispatches reset("addToIgnore") then Actions.addToIgnoreList()', () => {
const user = makeUser();
Dispatch.addToIgnoreList(user);
expect(store.dispatch).toHaveBeenNthCalledWith(1, (reset as jest.Mock)('addToIgnore'));
expect(store.dispatch).toHaveBeenNthCalledWith(2, Actions.addToIgnoreList(user));
});
it('removeFromIgnoreList dispatches Actions.removeFromIgnoreList()', () => {
Dispatch.removeFromIgnoreList('Bob');
expect(store.dispatch).toHaveBeenCalledWith(Actions.removeFromIgnoreList('Bob'));
});
it('updateInfo dispatches Actions.updateInfo({ name, version })', () => {
Dispatch.updateInfo('Servatrice', '2.9');
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateInfo({ name: 'Servatrice', version: '2.9' }));
});
it('updateStatus dispatches Actions.updateStatus({ state, description })', () => {
Dispatch.updateStatus(2, 'ok');
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateStatus({ state: 2, description: 'ok' }));
});
it('updateUser dispatches Actions.updateUser()', () => {
const user = makeUser();
Dispatch.updateUser(user);
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateUser(user));
});
it('updateUsers dispatches Actions.updateUsers()', () => {
const users = [makeUser()];
Dispatch.updateUsers(users);
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateUsers(users));
});
it('userJoined dispatches Actions.userJoined()', () => {
const user = makeUser();
Dispatch.userJoined(user);
expect(store.dispatch).toHaveBeenCalledWith(Actions.userJoined(user));
});
it('userLeft dispatches Actions.userLeft()', () => {
Dispatch.userLeft('Carol');
expect(store.dispatch).toHaveBeenCalledWith(Actions.userLeft('Carol'));
});
it('viewLogs dispatches Actions.viewLogs()', () => {
const logs = { room: [], game: [], chat: [] };
Dispatch.viewLogs(logs);
expect(store.dispatch).toHaveBeenCalledWith(Actions.viewLogs(logs));
});
it('clearLogs dispatches Actions.clearLogs()', () => {
Dispatch.clearLogs();
expect(store.dispatch).toHaveBeenCalledWith(Actions.clearLogs());
});
it('serverMessage dispatches Actions.serverMessage()', () => {
Dispatch.serverMessage('Welcome!');
expect(store.dispatch).toHaveBeenCalledWith(Actions.serverMessage('Welcome!'));
});
it('registrationRequiresEmail dispatches correctly', () => {
Dispatch.registrationRequiresEmail();
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationRequiresEmail());
});
it('registrationSuccess dispatches correctly', () => {
Dispatch.registrationSuccess();
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationSuccess());
});
it('registrationFailed dispatches correctly', () => {
Dispatch.registrationFailed('err');
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationFailed('err'));
});
it('registrationEmailError dispatches correctly', () => {
Dispatch.registrationEmailError('bad');
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationEmailError('bad'));
});
it('registrationPasswordError dispatches correctly', () => {
Dispatch.registrationPasswordError('weak');
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationPasswordError('weak'));
});
it('registrationUserNameError dispatches correctly', () => {
Dispatch.registrationUserNameError('taken');
expect(store.dispatch).toHaveBeenCalledWith(Actions.registrationUserNameError('taken'));
});
it('accountAwaitingActivation dispatches correctly', () => {
const options = makeConnectOptions();
Dispatch.accountAwaitingActivation(options);
expect(store.dispatch).toHaveBeenCalledWith(Actions.accountAwaitingActivation(options));
});
it('accountActivationSuccess dispatches correctly', () => {
Dispatch.accountActivationSuccess();
expect(store.dispatch).toHaveBeenCalledWith(Actions.accountActivationSuccess());
});
it('accountActivationFailed dispatches correctly', () => {
Dispatch.accountActivationFailed();
expect(store.dispatch).toHaveBeenCalledWith(Actions.accountActivationFailed());
});
it('resetPassword dispatches correctly', () => {
Dispatch.resetPassword();
expect(store.dispatch).toHaveBeenCalledWith(Actions.resetPassword());
});
it('resetPasswordFailed dispatches correctly', () => {
Dispatch.resetPasswordFailed();
expect(store.dispatch).toHaveBeenCalledWith(Actions.resetPasswordFailed());
});
it('resetPasswordChallenge dispatches correctly', () => {
Dispatch.resetPasswordChallenge();
expect(store.dispatch).toHaveBeenCalledWith(Actions.resetPasswordChallenge());
});
it('resetPasswordSuccess dispatches correctly', () => {
Dispatch.resetPasswordSuccess();
expect(store.dispatch).toHaveBeenCalledWith(Actions.resetPasswordSuccess());
});
it('adjustMod dispatches Actions.adjustMod()', () => {
Dispatch.adjustMod('Dan', true, false);
expect(store.dispatch).toHaveBeenCalledWith(Actions.adjustMod('Dan', true, false));
});
it('reloadConfig dispatches correctly', () => {
Dispatch.reloadConfig();
expect(store.dispatch).toHaveBeenCalledWith(Actions.reloadConfig());
});
it('shutdownServer dispatches correctly', () => {
Dispatch.shutdownServer();
expect(store.dispatch).toHaveBeenCalledWith(Actions.shutdownServer());
});
it('updateServerMessage dispatches correctly', () => {
Dispatch.updateServerMessage();
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateServerMessage());
});
it('accountPasswordChange dispatches correctly', () => {
Dispatch.accountPasswordChange();
expect(store.dispatch).toHaveBeenCalledWith(Actions.accountPasswordChange());
});
it('accountEditChanged dispatches correctly', () => {
const user = makeUser();
Dispatch.accountEditChanged(user);
expect(store.dispatch).toHaveBeenCalledWith(Actions.accountEditChanged(user));
});
it('accountImageChanged dispatches correctly', () => {
const user = makeUser();
Dispatch.accountImageChanged(user);
expect(store.dispatch).toHaveBeenCalledWith(Actions.accountImageChanged(user));
});
it('directMessageSent dispatches correctly', () => {
Dispatch.directMessageSent('Eve', 'hi');
expect(store.dispatch).toHaveBeenCalledWith(Actions.directMessageSent('Eve', 'hi'));
});
it('getUserInfo dispatches correctly', () => {
const userInfo = makeUser({ name: 'Frank' });
Dispatch.getUserInfo(userInfo);
expect(store.dispatch).toHaveBeenCalledWith(Actions.getUserInfo(userInfo));
});
it('notifyUser dispatches correctly', () => {
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' };
Dispatch.notifyUser(notification);
expect(store.dispatch).toHaveBeenCalledWith(Actions.notifyUser(notification));
});
it('serverShutdown dispatches correctly', () => {
const data = { reason: 'maintenance', minutes: 5 };
Dispatch.serverShutdown(data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.serverShutdown(data));
});
it('userMessage dispatches correctly', () => {
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hey' };
Dispatch.userMessage(messageData);
expect(store.dispatch).toHaveBeenCalledWith(Actions.userMessage(messageData));
});
it('addToList dispatches correctly', () => {
Dispatch.addToList('buddyList', 'Grace');
expect(store.dispatch).toHaveBeenCalledWith(Actions.addToList('buddyList', 'Grace'));
});
it('removeFromList dispatches correctly', () => {
Dispatch.removeFromList('buddyList', 'Hank');
expect(store.dispatch).toHaveBeenCalledWith(Actions.removeFromList('buddyList', 'Hank'));
});
it('banFromServer dispatches correctly', () => {
Dispatch.banFromServer('Ira');
expect(store.dispatch).toHaveBeenCalledWith(Actions.banFromServer('Ira'));
});
it('banHistory dispatches correctly', () => {
const history = [makeBanHistoryItem()];
Dispatch.banHistory('Ira', history);
expect(store.dispatch).toHaveBeenCalledWith(Actions.banHistory('Ira', history));
});
it('warnHistory dispatches correctly', () => {
const history = [makeWarnHistoryItem()];
Dispatch.warnHistory('Jack', history);
expect(store.dispatch).toHaveBeenCalledWith(Actions.warnHistory('Jack', history));
});
it('warnListOptions dispatches correctly', () => {
const list = [makeWarnListItem()];
Dispatch.warnListOptions(list);
expect(store.dispatch).toHaveBeenCalledWith(Actions.warnListOptions(list));
});
it('warnUser dispatches correctly', () => {
Dispatch.warnUser('Kelly');
expect(store.dispatch).toHaveBeenCalledWith(Actions.warnUser('Kelly'));
});
it('grantReplayAccess dispatches correctly', () => {
Dispatch.grantReplayAccess(7, 'Moe');
expect(store.dispatch).toHaveBeenCalledWith(Actions.grantReplayAccess(7, 'Moe'));
});
it('forceActivateUser dispatches correctly', () => {
Dispatch.forceActivateUser('Ned', 'Moe');
expect(store.dispatch).toHaveBeenCalledWith(Actions.forceActivateUser('Ned', 'Moe'));
});
it('getAdminNotes dispatches correctly', () => {
Dispatch.getAdminNotes('Ned', 'notes');
expect(store.dispatch).toHaveBeenCalledWith(Actions.getAdminNotes('Ned', 'notes'));
});
it('updateAdminNotes dispatches correctly', () => {
Dispatch.updateAdminNotes('Ned', 'updated');
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateAdminNotes('Ned', 'updated'));
});
it('replayList dispatches correctly', () => {
const list = [makeReplayMatch()];
Dispatch.replayList(list);
expect(store.dispatch).toHaveBeenCalledWith(Actions.replayList(list));
});
it('replayAdded dispatches correctly', () => {
const match = makeReplayMatch();
Dispatch.replayAdded(match);
expect(store.dispatch).toHaveBeenCalledWith(Actions.replayAdded(match));
});
it('replayModifyMatch dispatches correctly', () => {
Dispatch.replayModifyMatch(5, true);
expect(store.dispatch).toHaveBeenCalledWith(Actions.replayModifyMatch(5, true));
});
it('replayDeleteMatch dispatches correctly', () => {
Dispatch.replayDeleteMatch(5);
expect(store.dispatch).toHaveBeenCalledWith(Actions.replayDeleteMatch(5));
});
it('backendDecks dispatches correctly', () => {
const deckList = makeDeckList();
Dispatch.backendDecks(deckList);
expect(store.dispatch).toHaveBeenCalledWith(Actions.backendDecks(deckList));
});
it('deckNewDir dispatches correctly', () => {
Dispatch.deckNewDir('a/b', 'newFolder');
expect(store.dispatch).toHaveBeenCalledWith(Actions.deckNewDir('a/b', 'newFolder'));
});
it('deckDelDir dispatches correctly', () => {
Dispatch.deckDelDir('a/b');
expect(store.dispatch).toHaveBeenCalledWith(Actions.deckDelDir('a/b'));
});
it('deckUpload dispatches correctly', () => {
const treeItem = makeDeckTreeItem();
Dispatch.deckUpload('a/b', treeItem);
expect(store.dispatch).toHaveBeenCalledWith(Actions.deckUpload('a/b', treeItem));
});
it('deckDelete dispatches correctly', () => {
Dispatch.deckDelete(42);
expect(store.dispatch).toHaveBeenCalledWith(Actions.deckDelete(42));
});
});

View file

@ -0,0 +1,526 @@
import { StatusEnum, UserLevelFlag } from 'types';
import { serverReducer } from './server.reducer';
import { Types } from './server.types';
import {
makeBanHistoryItem,
makeConnectOptions,
makeDeckList,
makeDeckTreeItem,
makeLogItem,
makeReplayMatch,
makeServerState,
makeUser,
makeWarnHistoryItem,
makeWarnListItem,
} from './__mocks__/server-fixtures';
// ── Initialisation ───────────────────────────────────────────────────────────
describe('Initialisation', () => {
it('returns initialState when called with undefined state', () => {
const result = serverReducer(undefined, { type: '@@INIT' });
expect(result.initialized).toBe(false);
expect(result.buddyList).toEqual([]);
expect(result.status.state).toBe(StatusEnum.DISCONNECTED);
});
it('INITIALIZED → resets to initialState with initialized: true', () => {
const state = makeServerState({ banUser: 'someone', initialized: false });
const result = serverReducer(state, { type: Types.INITIALIZED });
expect(result.initialized).toBe(true);
expect(result.banUser).toBe('');
expect(result.buddyList).toEqual([]);
});
it('CLEAR_STORE → resets to initialState but preserves status', () => {
const status = { state: StatusEnum.LOGGED_IN, description: 'logged in' };
const state = makeServerState({ status, banUser: 'someone' });
const result = serverReducer(state, { type: Types.CLEAR_STORE });
expect(result.banUser).toBe('');
expect(result.status).toEqual(status);
expect(result.initialized).toBe(false);
});
it('default → returns state unchanged for unknown action', () => {
const state = makeServerState();
const result = serverReducer(state, { type: '@@UNKNOWN' });
expect(result).toBe(state);
});
});
// ── Account & Connection ─────────────────────────────────────────────────────
describe('Account & Connection', () => {
it('ACCOUNT_AWAITING_ACTIVATION → sets connectOptions from action.options', () => {
const options = makeConnectOptions();
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_AWAITING_ACTIVATION, options });
expect(result.connectOptions).toEqual(options);
});
it('ACCOUNT_ACTIVATION_SUCCESS → clears connectOptions to {}', () => {
const state = makeServerState({ connectOptions: makeConnectOptions() });
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_SUCCESS });
expect(result.connectOptions).toEqual({});
});
it('ACCOUNT_ACTIVATION_FAILED → clears connectOptions to {}', () => {
const state = makeServerState({ connectOptions: makeConnectOptions() });
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_FAILED });
expect(result.connectOptions).toEqual({});
});
});
// ── Server Info & Status ──────────────────────────────────────────────────────
describe('Server Info & Status', () => {
it('SERVER_MESSAGE → merges message into state.info', () => {
const state = makeServerState({ info: { message: null, name: 'Old', version: '1.0' } });
const result = serverReducer(state, { type: Types.SERVER_MESSAGE, message: 'Welcome!' });
expect(result.info.message).toBe('Welcome!');
expect(result.info.name).toBe('Old');
expect(result.info.version).toBe('1.0');
});
it('UPDATE_INFO → merges name and version into state.info (not message)', () => {
const state = makeServerState({ info: { message: 'hi', name: null, version: null } });
const result = serverReducer(state, {
type: Types.UPDATE_INFO,
info: { name: 'Servatrice', version: '2.9.0' },
});
expect(result.info.name).toBe('Servatrice');
expect(result.info.version).toBe('2.9.0');
expect(result.info.message).toBe('hi');
});
it('UPDATE_STATUS → replaces state.status entirely', () => {
const state = makeServerState();
const status = { state: StatusEnum.LOGGED_IN, description: 'ok' };
const result = serverReducer(state, { type: Types.UPDATE_STATUS, status });
expect(result.status).toEqual(status);
});
});
// ── User ──────────────────────────────────────────────────────────────────────
describe('User', () => {
it('UPDATE_USER → merges action.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice', userLevel: 1 }) });
const result = serverReducer(state, {
type: Types.UPDATE_USER,
user: { userLevel: 8 },
});
expect(result.user.name).toBe('Alice');
expect(result.user.userLevel).toBe(8);
});
it('ACCOUNT_EDIT_CHANGED → merges action.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }) });
const result = serverReducer(state, { type: Types.ACCOUNT_EDIT_CHANGED, user: { realName: 'Alice Smith' } });
expect(result.user.realName).toBe('Alice Smith');
expect(result.user.name).toBe('Alice');
});
it('ACCOUNT_IMAGE_CHANGED → merges action.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }) });
const result = serverReducer(state, { type: Types.ACCOUNT_IMAGE_CHANGED, user: { country: 'US' } });
expect(result.user.country).toBe('US');
});
});
// ── Users List ────────────────────────────────────────────────────────────────
describe('Users List', () => {
it('UPDATE_USERS → replaces users list and sorts by name ASC', () => {
const state = makeServerState();
const users = [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })];
const result = serverReducer(state, { type: Types.UPDATE_USERS, users });
expect(result.users[0].name).toBe('Alice');
expect(result.users[1].name).toBe('Zane');
});
it('USER_JOINED → appends user and sorts', () => {
const state = makeServerState({ users: [makeUser({ name: 'Zane' })] });
const result = serverReducer(state, { type: Types.USER_JOINED, user: makeUser({ name: 'Alice' }) });
expect(result.users[0].name).toBe('Alice');
expect(result.users[1].name).toBe('Zane');
});
it('USER_LEFT → removes user by name', () => {
const state = makeServerState({ users: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
const result = serverReducer(state, { type: Types.USER_LEFT, name: 'Alice' });
expect(result.users).toHaveLength(1);
expect(result.users[0].name).toBe('Bob');
});
});
// ── Buddy & Ignore Lists ──────────────────────────────────────────────────────
describe('Buddy List', () => {
it('UPDATE_BUDDY_LIST → replaces and sorts buddy list', () => {
const state = makeServerState();
const buddyList = [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })];
const result = serverReducer(state, { type: Types.UPDATE_BUDDY_LIST, buddyList });
expect(result.buddyList[0].name).toBe('Alice');
});
it('ADD_TO_BUDDY_LIST → appends user and sorts', () => {
const state = makeServerState({ buddyList: [makeUser({ name: 'Zane' })] });
const result = serverReducer(state, { type: Types.ADD_TO_BUDDY_LIST, user: makeUser({ name: 'Alice' }) });
expect(result.buddyList[0].name).toBe('Alice');
expect(result.buddyList).toHaveLength(2);
});
it('REMOVE_FROM_BUDDY_LIST → removes user by name', () => {
const state = makeServerState({ buddyList: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
const result = serverReducer(state, { type: Types.REMOVE_FROM_BUDDY_LIST, userName: 'Alice' });
expect(result.buddyList).toHaveLength(1);
expect(result.buddyList[0].name).toBe('Bob');
});
});
describe('Ignore List', () => {
it('UPDATE_IGNORE_LIST → replaces and sorts ignore list', () => {
const state = makeServerState();
const ignoreList = [makeUser({ name: 'Zane' }), makeUser({ name: 'Alice' })];
const result = serverReducer(state, { type: Types.UPDATE_IGNORE_LIST, ignoreList });
expect(result.ignoreList[0].name).toBe('Alice');
});
it('ADD_TO_IGNORE_LIST → appends user and sorts', () => {
const state = makeServerState({ ignoreList: [makeUser({ name: 'Zane' })] });
const result = serverReducer(state, { type: Types.ADD_TO_IGNORE_LIST, user: makeUser({ name: 'Alice' }) });
expect(result.ignoreList[0].name).toBe('Alice');
expect(result.ignoreList).toHaveLength(2);
});
it('REMOVE_FROM_IGNORE_LIST → removes user by name', () => {
const state = makeServerState({ ignoreList: [makeUser({ name: 'Alice' }), makeUser({ name: 'Bob' })] });
const result = serverReducer(state, { type: Types.REMOVE_FROM_IGNORE_LIST, userName: 'Alice' });
expect(result.ignoreList).toHaveLength(1);
expect(result.ignoreList[0].name).toBe('Bob');
});
});
// ── Logs ─────────────────────────────────────────────────────────────────────
describe('Logs', () => {
it('VIEW_LOGS → replaces logs entirely', () => {
const logs = { room: [makeLogItem()], game: [], chat: [] };
const state = makeServerState();
const result = serverReducer(state, { type: Types.VIEW_LOGS, logs });
expect(result.logs).toEqual(logs);
});
it('CLEAR_LOGS → resets logs to empty arrays', () => {
const state = makeServerState({ logs: { room: [makeLogItem()], game: [], chat: [] } });
const result = serverReducer(state, { type: Types.CLEAR_LOGS });
expect(result.logs.room).toEqual([]);
expect(result.logs.game).toEqual([]);
expect(result.logs.chat).toEqual([]);
});
});
// ── Messaging ─────────────────────────────────────────────────────────────────
describe('Messaging', () => {
it('USER_MESSAGE → uses receiverName as key when current user is sender', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }), messages: {} });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hi' };
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData });
expect(result.messages['Bob']).toHaveLength(1);
expect(result.messages['Bob'][0]).toBe(messageData);
});
it('USER_MESSAGE → uses senderName as key when current user is receiver', () => {
const state = makeServerState({ user: makeUser({ name: 'Bob' }), messages: {} });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'yo' };
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData });
expect(result.messages['Alice']).toHaveLength(1);
expect(result.messages['Alice'][0]).toBe(messageData);
});
it('USER_MESSAGE → appends to existing messages for that user', () => {
const existingMsg = { senderName: 'Alice', receiverName: 'Bob', message: 'first' };
const state = makeServerState({
user: makeUser({ name: 'Bob' }),
messages: { Alice: [existingMsg] },
});
const newMsg = { senderName: 'Alice', receiverName: 'Bob', message: 'second' };
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData: newMsg });
expect(result.messages['Alice']).toHaveLength(2);
});
});
// ── User Info & Notifications ─────────────────────────────────────────────────
describe('User Info & Notifications', () => {
it('GET_USER_INFO → adds userInfo keyed by name', () => {
const userInfo = makeUser({ name: 'Eve' });
const state = makeServerState();
const result = serverReducer(state, { type: Types.GET_USER_INFO, userInfo });
expect(result.userInfo['Eve']).toBe(userInfo);
});
it('NOTIFY_USER → appends notification to list', () => {
const state = makeServerState({ notifications: [] });
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' };
const result = serverReducer(state, { type: Types.NOTIFY_USER, notification });
expect(result.notifications).toHaveLength(1);
expect(result.notifications[0]).toBe(notification);
});
it('SERVER_SHUTDOWN → sets serverShutdown to action.data', () => {
const data = { reason: 'maintenance', minutes: 10 };
const state = makeServerState();
const result = serverReducer(state, { type: Types.SERVER_SHUTDOWN, data });
expect(result.serverShutdown).toBe(data);
});
});
// ── Moderation ────────────────────────────────────────────────────────────────
describe('Moderation', () => {
it('BAN_FROM_SERVER → sets banUser', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.BAN_FROM_SERVER, userName: 'Frank' });
expect(result.banUser).toBe('Frank');
});
it('BAN_HISTORY → adds banHistory keyed by userName', () => {
const history = [makeBanHistoryItem()];
const state = makeServerState();
const result = serverReducer(state, { type: Types.BAN_HISTORY, userName: 'Frank', banHistory: history });
expect(result.banHistory['Frank']).toBe(history);
});
it('WARN_HISTORY → adds warnHistory keyed by userName', () => {
const history = [makeWarnHistoryItem()];
const state = makeServerState();
const result = serverReducer(state, { type: Types.WARN_HISTORY, userName: 'Grace', warnHistory: history });
expect(result.warnHistory['Grace']).toBe(history);
});
it('WARN_LIST_OPTIONS → replaces warnListOptions', () => {
const list = [makeWarnListItem()];
const state = makeServerState();
const result = serverReducer(state, { type: Types.WARN_LIST_OPTIONS, warnList: list });
expect(result.warnListOptions).toBe(list);
});
it('WARN_USER → sets warnUser', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.WARN_USER, userName: 'Hank' });
expect(result.warnUser).toBe('Hank');
});
it('GET_ADMIN_NOTES → adds adminNotes keyed by userName', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.GET_ADMIN_NOTES, userName: 'Ira', notes: 'note1' });
expect(result.adminNotes['Ira']).toBe('note1');
});
it('UPDATE_ADMIN_NOTES → updates adminNotes keyed by userName', () => {
const state = makeServerState({ adminNotes: { Ira: 'old' } });
const result = serverReducer(state, { type: Types.UPDATE_ADMIN_NOTES, userName: 'Ira', notes: 'new' });
expect(result.adminNotes['Ira']).toBe('new');
});
});
// ── ADJUST_MOD ────────────────────────────────────────────────────────────────
describe('ADJUST_MOD', () => {
const baseUserLevel = UserLevelFlag.IsUser | UserLevelFlag.IsRegistered | UserLevelFlag.IsModerator | UserLevelFlag.IsJudge;
it('shouldBeMod=true, shouldBeJudge=true → keeps IsModerator and IsJudge bits', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: true });
// IsModerator(4) | IsJudge(16)
expect(result.users[0].userLevel).toBe(20);
});
it('shouldBeMod=true, shouldBeJudge=false → keeps only IsModerator bit', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: false });
// IsModerator(4)
expect(result.users[0].userLevel).toBe(4);
});
it('shouldBeMod=false, shouldBeJudge=true → keeps only IsJudge bit', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: true });
// IsJudge(16)
expect(result.users[0].userLevel).toBe(16);
});
it('shouldBeMod=false, shouldBeJudge=false → clears both bits', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: false });
expect(result.users[0].userLevel).toBe(0);
});
it('non-matching users are left unchanged', () => {
const alice = makeUser({ name: 'Alice', userLevel: 7 });
const state = makeServerState({ users: [alice, makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: false });
expect(result.users.find(u => u.name === 'Alice').userLevel).toBe(7);
});
});
// ── Replays ───────────────────────────────────────────────────────────────────
describe('Replays', () => {
it('REPLAY_LIST → replaces replays list', () => {
const matchList = [makeReplayMatch({ gameId: 10 })];
const state = makeServerState({ replays: [makeReplayMatch({ gameId: 99 })] });
const result = serverReducer(state, { type: Types.REPLAY_LIST, matchList });
expect(result.replays).toHaveLength(1);
expect(result.replays[0].gameId).toBe(10);
});
it('REPLAY_ADDED → appends matchInfo to replays', () => {
const existing = makeReplayMatch({ gameId: 1 });
const added = makeReplayMatch({ gameId: 2 });
const state = makeServerState({ replays: [existing] });
const result = serverReducer(state, { type: Types.REPLAY_ADDED, matchInfo: added });
expect(result.replays).toHaveLength(2);
expect(result.replays[1]).toBe(added);
});
it('REPLAY_MODIFY_MATCH → updates doNotHide for matching gameId', () => {
const state = makeServerState({ replays: [makeReplayMatch({ gameId: 5, doNotHide: false })] });
const result = serverReducer(state, { type: Types.REPLAY_MODIFY_MATCH, gameId: 5, doNotHide: true });
expect(result.replays[0].doNotHide).toBe(true);
});
it('REPLAY_MODIFY_MATCH → leaves non-matching replays unchanged', () => {
const r1 = makeReplayMatch({ gameId: 1, doNotHide: false });
const r2 = makeReplayMatch({ gameId: 2, doNotHide: false });
const state = makeServerState({ replays: [r1, r2] });
const result = serverReducer(state, { type: Types.REPLAY_MODIFY_MATCH, gameId: 1, doNotHide: true });
expect(result.replays[1].doNotHide).toBe(false);
});
it('REPLAY_DELETE_MATCH → removes replay by gameId', () => {
const state = makeServerState({ replays: [makeReplayMatch({ gameId: 5 }), makeReplayMatch({ gameId: 6 })] });
const result = serverReducer(state, { type: Types.REPLAY_DELETE_MATCH, gameId: 5 });
expect(result.replays).toHaveLength(1);
expect(result.replays[0].gameId).toBe(6);
});
});
// ── Deck Storage ──────────────────────────────────────────────────────────────
describe('Deck Storage', () => {
it('BACKEND_DECKS → sets backendDecks', () => {
const deckList = makeDeckList();
const state = makeServerState();
const result = serverReducer(state, { type: Types.BACKEND_DECKS, deckList });
expect(result.backendDecks).toBe(deckList);
});
it('DECK_UPLOAD with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: '', treeItem: makeDeckTreeItem() });
expect(result).toBe(state);
});
it('DECK_UPLOAD with flat path → appends item to root', () => {
const state = makeServerState({ backendDecks: makeDeckList() });
const item = makeDeckTreeItem({ name: 'deck.cod' });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: '', treeItem: item });
expect(result.backendDecks.root.items).toHaveLength(1);
expect(result.backendDecks.root.items[0]).toBe(item);
});
it('DECK_UPLOAD with nested path → inserts into matching subfolder', () => {
const subfolder = { id: 0, name: 'myDecks', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const item = makeDeckTreeItem({ name: 'new.cod' });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: 'myDecks', treeItem: item });
const folder = result.backendDecks.root.items.find(i => i.name === 'myDecks');
expect(folder.folder.items).toHaveLength(1);
expect(folder.folder.items[0]).toBe(item);
});
it('DECK_UPLOAD with non-existent intermediate folder → creates folder and inserts', () => {
const state = makeServerState({ backendDecks: makeDeckList() });
const item = makeDeckTreeItem({ name: 'deck.cod' });
const result = serverReducer(state, { type: Types.DECK_UPLOAD, path: 'newFolder', treeItem: item });
expect(result.backendDecks.root.items).toHaveLength(1);
expect(result.backendDecks.root.items[0].name).toBe('newFolder');
expect(result.backendDecks.root.items[0].folder.items[0]).toBe(item);
});
it('DECK_DELETE with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 1 });
expect(result).toBe(state);
});
it('DECK_DELETE → removes item by id from tree', () => {
const item = makeDeckTreeItem({ id: 7 });
const state = makeServerState({ backendDecks: { root: { items: [item] } } });
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 7 });
expect(result.backendDecks.root.items).toHaveLength(0);
});
it('DECK_DELETE → recursively removes item nested inside a subfolder', () => {
const nested = makeDeckTreeItem({ id: 9, name: 'nested.cod' });
const subfolder = { id: 0, name: 'sub', file: null, folder: { items: [nested] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 9 });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
});
it('DECK_NEW_DIR with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: '', dirName: 'newDir' });
expect(result).toBe(state);
});
it('DECK_NEW_DIR at root → appends folder to root items', () => {
const state = makeServerState({ backendDecks: makeDeckList() });
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: '', dirName: 'myDir' });
expect(result.backendDecks.root.items).toHaveLength(1);
expect(result.backendDecks.root.items[0].name).toBe('myDir');
expect(result.backendDecks.root.items[0].folder).toEqual({ items: [] });
});
it('DECK_NEW_DIR nested → inserts folder inside matching subfolder', () => {
const subfolder = { id: 0, name: 'parent', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const result = serverReducer(state, { type: Types.DECK_NEW_DIR, path: 'parent', dirName: 'child' });
const parent = result.backendDecks.root.items.find(i => i.name === 'parent');
expect(parent.folder.items).toHaveLength(1);
expect(parent.folder.items[0].name).toBe('child');
});
it('DECK_DEL_DIR with null backendDecks → returns state unchanged', () => {
const state = makeServerState({ backendDecks: null });
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'myDir' });
expect(result).toBe(state);
});
it('DECK_DEL_DIR → removes folder from root by name', () => {
const subfolder = { id: 0, name: 'myDir', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'myDir' });
expect(result.backendDecks.root.items).toHaveLength(0);
});
it('DECK_DEL_DIR → returns deck tree unchanged when path is empty', () => {
const subfolder = { id: 0, name: 'keep', file: null, folder: { items: [] } };
const state = makeServerState({ backendDecks: { root: { items: [subfolder] } } });
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: '' });
expect(result.backendDecks.root.items).toHaveLength(1);
});
it('DECK_DEL_DIR → recursively removes nested subfolder via multi-segment path', () => {
const child = { id: 0, name: 'child', file: null, folder: { items: [] } };
const parent = { id: 0, name: 'parent', file: null, folder: { items: [child] } };
const state = makeServerState({ backendDecks: { root: { items: [parent] } } });
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'parent/child' });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
});
});

View file

@ -0,0 +1,98 @@
import { Selectors } from './server.selectors';
import { ServerState } from './server.interfaces';
import {
makeDeckList,
makeReplayMatch,
makeServerState,
makeUser,
} from './__mocks__/server-fixtures';
import { StatusEnum } from 'types';
function rootState(server: ServerState) {
return { server };
}
describe('Selectors', () => {
it('getInitialized → returns initialized flag', () => {
const state = makeServerState({ initialized: true });
expect(Selectors.getInitialized(rootState(state))).toBe(true);
});
it('getConnectOptions → returns connectOptions', () => {
const connectOptions = { host: 'localhost', port: '4747' };
const state = makeServerState({ connectOptions });
expect(Selectors.getConnectOptions(rootState(state))).toBe(connectOptions);
});
it('getMessage → returns info.message', () => {
const state = makeServerState({ info: { message: 'Welcome!', name: null, version: null } });
expect(Selectors.getMessage(rootState(state))).toBe('Welcome!');
});
it('getName → returns info.name', () => {
const state = makeServerState({ info: { message: null, name: 'Servatrice', version: null } });
expect(Selectors.getName(rootState(state))).toBe('Servatrice');
});
it('getVersion → returns info.version', () => {
const state = makeServerState({ info: { message: null, name: null, version: '2.9.0' } });
expect(Selectors.getVersion(rootState(state))).toBe('2.9.0');
});
it('getDescription → returns status.description', () => {
const state = makeServerState({ status: { state: StatusEnum.CONNECTED, description: 'ok' } });
expect(Selectors.getDescription(rootState(state))).toBe('ok');
});
it('getState → returns status.state', () => {
const state = makeServerState({ status: { state: StatusEnum.LOGGED_IN, description: null } });
expect(Selectors.getState(rootState(state))).toBe(StatusEnum.LOGGED_IN);
});
it('getUser → returns user', () => {
const user = makeUser({ name: 'Alice' });
const state = makeServerState({ user });
expect(Selectors.getUser(rootState(state))).toBe(user);
});
it('getUsers → returns users array', () => {
const users = [makeUser(), makeUser({ name: 'Bob' })];
const state = makeServerState({ users });
expect(Selectors.getUsers(rootState(state))).toBe(users);
});
it('getLogs → returns logs object', () => {
const logs = { room: [], game: [], chat: [] };
const state = makeServerState({ logs });
expect(Selectors.getLogs(rootState(state))).toBe(logs);
});
it('getBuddyList → returns buddyList', () => {
const buddyList = [makeUser({ name: 'Carol' })];
const state = makeServerState({ buddyList });
expect(Selectors.getBuddyList(rootState(state))).toBe(buddyList);
});
it('getIgnoreList → returns ignoreList', () => {
const ignoreList = [makeUser({ name: 'Dave' })];
const state = makeServerState({ ignoreList });
expect(Selectors.getIgnoreList(rootState(state))).toBe(ignoreList);
});
it('getReplays → returns replays', () => {
const replays = [makeReplayMatch()];
const state = makeServerState({ replays });
expect(Selectors.getReplays(rootState(state))).toBe(replays);
});
it('getBackendDecks → returns backendDecks', () => {
const backendDecks = makeDeckList();
const state = makeServerState({ backendDecks });
expect(Selectors.getBackendDecks(rootState(state))).toBe(backendDecks);
});
it('getBackendDecks → returns null when not set', () => {
const state = makeServerState({ backendDecks: null });
expect(Selectors.getBackendDecks(rootState(state))).toBeNull();
});
});