diff --git a/webclient/src/api/AdminService.spec.ts b/webclient/src/api/AdminService.spec.ts new file mode 100644 index 000000000..be8e84c99 --- /dev/null +++ b/webclient/src/api/AdminService.spec.ts @@ -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(); + }); + }); +}); diff --git a/webclient/src/api/AuthenticationService.spec.ts b/webclient/src/api/AuthenticationService.spec.ts new file mode 100644 index 000000000..234ecbe47 --- /dev/null +++ b/webclient/src/api/AuthenticationService.spec.ts @@ -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); + }); + }); +}); diff --git a/webclient/src/api/ModeratorService.spec.ts b/webclient/src/api/ModeratorService.spec.ts new file mode 100644 index 000000000..ab83bfdd2 --- /dev/null +++ b/webclient/src/api/ModeratorService.spec.ts @@ -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); + }); + }); +}); diff --git a/webclient/src/api/RoomsService.spec.ts b/webclient/src/api/RoomsService.spec.ts new file mode 100644 index 000000000..c99a5ed06 --- /dev/null +++ b/webclient/src/api/RoomsService.spec.ts @@ -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'); + }); + }); +}); diff --git a/webclient/src/api/SessionService.spec.ts b/webclient/src/api/SessionService.spec.ts new file mode 100644 index 000000000..b60631448 --- /dev/null +++ b/webclient/src/api/SessionService.spec.ts @@ -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'); + }); + }); +}); diff --git a/webclient/src/store/actions/actionReducer.spec.ts b/webclient/src/store/actions/actionReducer.spec.ts new file mode 100644 index 000000000..351995dc5 --- /dev/null +++ b/webclient/src/store/actions/actionReducer.spec.ts @@ -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); + }); +}); diff --git a/webclient/src/store/common/SortUtil.spec.ts b/webclient/src/store/common/SortUtil.spec.ts new file mode 100644 index 000000000..6581b640c --- /dev/null +++ b/webclient/src/store/common/SortUtil.spec.ts @@ -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'); + }); +}); diff --git a/webclient/src/store/common/SortUtil.ts b/webclient/src/store/common/SortUtil.ts index 56910259d..f6aca821d 100644 --- a/webclient/src/store/common/SortUtil.ts +++ b/webclient/src/store/common/SortUtil.ts @@ -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; diff --git a/webclient/src/store/game/game.reducer.spec.ts b/webclient/src/store/game/game.reducer.spec.ts index 92e8477cf..38ed89ad1 100644 --- a/webclient/src/store/game/game.reducer.spec.ts +++ b/webclient/src/store/game/game.reducer.spec.ts @@ -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 ──────────────────────────────────────────────────────── diff --git a/webclient/src/store/rooms/__mocks__/rooms-fixtures.ts b/webclient/src/store/rooms/__mocks__/rooms-fixtures.ts new file mode 100644 index 000000000..771fda745 --- /dev/null +++ b/webclient/src/store/rooms/__mocks__/rooms-fixtures.ts @@ -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 { + return { + name: 'TestUser', + accountageSecs: 0, + privlevel: UserPrivLevel.NONE, + userLevel: 0, + ...overrides, + }; +} + +export function makeRoom(overrides: Partial = {}): 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 } { + return { + gameId: 1, + roomId: 1, + description: 'Test Game', + gameType: '', + gameTypes: [], + started: false, + startTime: 0, + ...overrides, + }; +} + +export function makeMessage(overrides: Partial = {}): Message { + return { + message: 'hello', + messageType: 0, + timeReceived: 0, + ...overrides, + }; +} + +export function makeRoomsState(overrides: Partial = {}): 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, + }; +} diff --git a/webclient/src/store/rooms/rooms.actions.spec.ts b/webclient/src/store/rooms/rooms.actions.spec.ts new file mode 100644 index 000000000..542d626b9 --- /dev/null +++ b/webclient/src/store/rooms/rooms.actions.spec.ts @@ -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 }); + }); +}); diff --git a/webclient/src/store/rooms/rooms.dispatch.spec.ts b/webclient/src/store/rooms/rooms.dispatch.spec.ts new file mode 100644 index 000000000..9da0f9d5c --- /dev/null +++ b/webclient/src/store/rooms/rooms.dispatch.spec.ts @@ -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)); + }); +}); diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts new file mode 100644 index 000000000..2555a12f2 --- /dev/null +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -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); + }); +}); diff --git a/webclient/src/store/rooms/rooms.selectors.spec.ts b/webclient/src/store/rooms/rooms.selectors.spec.ts new file mode 100644 index 000000000..f8c6ef4cc --- /dev/null +++ b/webclient/src/store/rooms/rooms.selectors.spec.ts @@ -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); + }); +}); diff --git a/webclient/src/store/server/__mocks__/server-fixtures.ts b/webclient/src/store/server/__mocks__/server-fixtures.ts new file mode 100644 index 000000000..6ac04c121 --- /dev/null +++ b/webclient/src/store/server/__mocks__/server-fixtures.ts @@ -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 { + return { + name: 'TestUser', + accountageSecs: 0, + privlevel: UserPrivLevel.NONE, + userLevel: 0, + ...overrides, + }; +} + +export function makeLogItem(overrides: Partial = {}): LogItem { + return { + message: '', + senderId: '', + senderIp: '', + senderName: '', + targetId: '', + targetName: '', + targetType: '', + time: '', + ...overrides, + }; +} + +export function makeBanHistoryItem(overrides: Partial = {}): BanHistoryItem { + return { + adminId: '', + adminName: '', + banTime: '', + banLength: '', + banReason: '', + visibleReason: '', + ...overrides, + }; +} + +export function makeWarnHistoryItem(overrides: Partial = {}): WarnHistoryItem { + return { + userName: '', + adminName: '', + reason: '', + timeOf: '', + ...overrides, + }; +} + +export function makeWarnListItem(overrides: Partial = {}): WarnListItem { + return { + warning: '', + userName: '', + userClientid: '', + ...overrides, + }; +} + +export function makeDeckTreeItem(overrides: Partial = {}): DeckStorageTreeItem { + return { + id: 1, + name: 'item', + file: { creationTime: 0 }, + folder: null, + ...overrides, + }; +} + +export function makeDeckList(overrides: Partial = {}): DeckList { + return { + root: { items: [] }, + ...overrides, + }; +} + +export function makeReplayMatch(overrides: Partial = {}): 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 { + return { + host: 'localhost', + port: '4747', + userName: 'user', + password: 'pass', + ...overrides, + }; +} + +export function makeServerState(overrides: Partial = {}): 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, + }; +} diff --git a/webclient/src/store/server/server.actions.spec.ts b/webclient/src/store/server/server.actions.spec.ts new file mode 100644 index 000000000..7d61717fb --- /dev/null +++ b/webclient/src/store/server/server.actions.spec.ts @@ -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 }); + }); +}); diff --git a/webclient/src/store/server/server.dispatch.spec.ts b/webclient/src/store/server/server.dispatch.spec.ts new file mode 100644 index 000000000..f6bab848d --- /dev/null +++ b/webclient/src/store/server/server.dispatch.spec.ts @@ -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)); + }); +}); diff --git a/webclient/src/store/server/server.reducer.spec.ts b/webclient/src/store/server/server.reducer.spec.ts new file mode 100644 index 000000000..e6ba27680 --- /dev/null +++ b/webclient/src/store/server/server.reducer.spec.ts @@ -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); + }); +}); diff --git a/webclient/src/store/server/server.selectors.spec.ts b/webclient/src/store/server/server.selectors.spec.ts new file mode 100644 index 000000000..711513150 --- /dev/null +++ b/webclient/src/store/server/server.selectors.spec.ts @@ -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(); + }); +});