refactor redux data model

This commit is contained in:
seavor 2026-04-15 21:48:03 -05:00
parent ae1bc3da38
commit 0ff391491d
243 changed files with 5212 additions and 5963 deletions

View file

@ -1,7 +1,7 @@
import { App, Data } from '@app/types';
import { create } from '@bufbuild/protobuf';
import { serverReducer } from './server.reducer';
import { Types } from './server.types';
import { Actions } from './server.actions';
import {
makeBanHistoryItem,
makePendingActivationContext,
@ -24,22 +24,22 @@ 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.buddyList).toEqual({});
expect(result.status.state).toBe(App.StatusEnum.DISCONNECTED);
});
it('INITIALIZED → resets to initialState with initialized: true', () => {
const state = makeServerState({ banUser: 'someone', initialized: false });
const result = serverReducer(state, { type: Types.INITIALIZED });
const result = serverReducer(state, Actions.initialized());
expect(result.initialized).toBe(true);
expect(result.banUser).toBe('');
expect(result.buddyList).toEqual([]);
expect(result.buddyList).toEqual({});
});
it('CLEAR_STORE → resets to initialState but preserves status', () => {
const status = { state: App.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true };
const state = makeServerState({ status, banUser: 'someone' });
const result = serverReducer(state, { type: Types.CLEAR_STORE });
const result = serverReducer(state, Actions.clearStore());
expect(result.banUser).toBe('');
expect(result.status).toEqual(status);
expect(result.initialized).toBe(false);
@ -48,7 +48,7 @@ describe('Initialisation', () => {
it('default → returns state unchanged for unknown action', () => {
const state = makeServerState();
const result = serverReducer(state, { type: '@@UNKNOWN' });
expect(result).toBe(state);
expect(result).toEqual(state);
});
});
@ -57,27 +57,27 @@ describe('Initialisation', () => {
describe('Account & Connection', () => {
it('CONNECTION_ATTEMPTED → sets connectionAttemptMade to true', () => {
const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } });
const result = serverReducer(state, { type: Types.CONNECTION_ATTEMPTED });
const result = serverReducer(state, Actions.connectionAttempted());
expect(result.status.connectionAttemptMade).toBe(true);
});
it('ACCOUNT_AWAITING_ACTIVATION → returns state unchanged', () => {
const options = makePendingActivationContext();
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_AWAITING_ACTIVATION, options });
expect(result).toBe(state);
const result = serverReducer(state, Actions.accountAwaitingActivation({ options }));
expect(result).toEqual(state);
});
it('ACCOUNT_ACTIVATION_SUCCESS → returns state unchanged', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_SUCCESS });
expect(result).toBe(state);
const result = serverReducer(state, Actions.accountActivationSuccess());
expect(result).toEqual(state);
});
it('ACCOUNT_ACTIVATION_FAILED → returns state unchanged', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_FAILED });
expect(result).toBe(state);
const result = serverReducer(state, Actions.accountActivationFailed());
expect(result).toEqual(state);
});
});
@ -86,26 +86,26 @@ describe('Account & Connection', () => {
describe('Registration', () => {
it('REGISTRATION_FAILED → stores normalized error (plain reason)', () => {
const state = makeServerState({ registrationError: null });
const result = serverReducer(state, { type: Types.REGISTRATION_FAILED, reason: 'Server is disabled', endTime: undefined });
const result = serverReducer(state, Actions.registrationFailed({ reason: 'Server is disabled', endTime: undefined }));
expect(result.registrationError).toBe('Server is disabled');
});
it('REGISTRATION_FAILED → normalizes banned error when endTime is given', () => {
const state = makeServerState({ registrationError: null });
const result = serverReducer(state, { type: Types.REGISTRATION_FAILED, reason: 'bad actor', endTime: Date.now() + 100_000 });
const result = serverReducer(state, Actions.registrationFailed({ reason: 'bad actor', endTime: Date.now() + 100_000 }));
expect(result.registrationError).toContain('banned');
expect(result.registrationError).toContain('bad actor');
});
it('CLEAR_REGISTRATION_ERRORS → sets registrationError to null', () => {
const state = makeServerState({ registrationError: 'some error' });
const result = serverReducer(state, { type: Types.CLEAR_REGISTRATION_ERRORS });
const result = serverReducer(state, Actions.clearRegistrationErrors());
expect(result.registrationError).toBeNull();
});
it('CLEAR_STORE → resets registrationError to null', () => {
const state = makeServerState({ registrationError: 'stale error' });
const result = serverReducer(state, { type: Types.CLEAR_STORE });
const result = serverReducer(state, Actions.clearStore());
expect(result.registrationError).toBeNull();
});
});
@ -115,7 +115,7 @@ describe('Registration', () => {
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!' });
const result = serverReducer(state, Actions.serverMessage({ message: 'Welcome!' }));
expect(result.info.message).toBe('Welcome!');
expect(result.info.name).toBe('Old');
expect(result.info.version).toBe('1.0');
@ -123,10 +123,7 @@ describe('Server Info & Status', () => {
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' },
});
const result = serverReducer(state, Actions.updateInfo({ 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');
@ -135,7 +132,7 @@ describe('Server Info & Status', () => {
it('UPDATE_STATUS → merges state and description into status', () => {
const state = makeServerState();
const update = { state: App.StatusEnum.LOGGED_IN, description: 'ok' };
const result = serverReducer(state, { type: Types.UPDATE_STATUS, status: update });
const result = serverReducer(state, Actions.updateStatus({ status: update }));
expect(result.status.state).toBe(App.StatusEnum.LOGGED_IN);
expect(result.status.description).toBe('ok');
expect(result.status.connectionAttemptMade).toBe(false);
@ -145,26 +142,23 @@ describe('Server Info & Status', () => {
// ── User ──────────────────────────────────────────────────────────────────────
describe('User', () => {
it('UPDATE_USER → merges action.user into state.user', () => {
it('UPDATE_USER → merges action.payload.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice', userLevel: 1 }) });
const result = serverReducer(state, {
type: Types.UPDATE_USER,
user: { userLevel: 8 },
});
const result = serverReducer(state, Actions.updateUser({ user: { userLevel: 8 } as any }));
expect(result.user.name).toBe('Alice');
expect(result.user.userLevel).toBe(8);
});
it('ACCOUNT_EDIT_CHANGED → merges action.user into state.user', () => {
it('ACCOUNT_EDIT_CHANGED → merges action.payload.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }) });
const result = serverReducer(state, { type: Types.ACCOUNT_EDIT_CHANGED, user: { realName: 'Alice Smith' } });
const result = serverReducer(state, Actions.accountEditChanged({ 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', () => {
it('ACCOUNT_IMAGE_CHANGED → merges action.payload.user into state.user', () => {
const state = makeServerState({ user: makeUser({ name: 'Alice' }) });
const result = serverReducer(state, { type: Types.ACCOUNT_IMAGE_CHANGED, user: { country: 'US' } });
const result = serverReducer(state, Actions.accountImageChanged({ user: { country: 'US' } }));
expect(result.user.country).toBe('US');
});
});
@ -172,74 +166,83 @@ describe('User', () => {
// ── Users List ────────────────────────────────────────────────────────────────
describe('Users List', () => {
it('UPDATE_USERS → replaces users list and sorts by name ASC', () => {
it('UPDATE_USERS → replaces users map keyed by name', () => {
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');
const result = serverReducer(state, Actions.updateUsers({ users }));
expect(result.users['Alice']).toBeDefined();
expect(result.users['Zane']).toBeDefined();
expect(Object.keys(result.users)).toHaveLength(2);
});
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_JOINED → inserts user into map', () => {
const state = makeServerState({ users: { Zane: makeUser({ name: 'Zane' }) } });
const result = serverReducer(state, Actions.userJoined({ user: makeUser({ name: 'Alice' }) }));
expect(result.users['Alice']).toBeDefined();
expect(result.users['Zane']).toBeDefined();
});
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');
it('USER_LEFT → removes user by name from map', () => {
const state = makeServerState({
users: { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) },
});
const result = serverReducer(state, Actions.userLeft({ name: 'Alice' }));
expect(result.users['Alice']).toBeUndefined();
expect(result.users['Bob']).toBeDefined();
});
});
// ── Buddy & Ignore Lists ──────────────────────────────────────────────────────
describe('Buddy List', () => {
it('UPDATE_BUDDY_LIST → replaces and sorts buddy list', () => {
it('UPDATE_BUDDY_LIST → replaces map keyed by name', () => {
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');
const result = serverReducer(state, Actions.updateBuddyList({ buddyList }));
expect(result.buddyList['Alice']).toBeDefined();
expect(result.buddyList['Zane']).toBeDefined();
});
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('ADD_TO_BUDDY_LIST → inserts user into map', () => {
const state = makeServerState({ buddyList: { Zane: makeUser({ name: 'Zane' }) } });
const result = serverReducer(state, Actions.addToBuddyList({ user: makeUser({ name: 'Alice' }) }));
expect(result.buddyList['Alice']).toBeDefined();
expect(Object.keys(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');
it('REMOVE_FROM_BUDDY_LIST → removes user by name from map', () => {
const state = makeServerState({
buddyList: { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) },
});
const result = serverReducer(state, Actions.removeFromBuddyList({ userName: 'Alice' }));
expect(result.buddyList['Alice']).toBeUndefined();
expect(result.buddyList['Bob']).toBeDefined();
});
});
describe('Ignore List', () => {
it('UPDATE_IGNORE_LIST → replaces and sorts ignore list', () => {
it('UPDATE_IGNORE_LIST → replaces map keyed by name', () => {
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');
const result = serverReducer(state, Actions.updateIgnoreList({ ignoreList }));
expect(result.ignoreList['Alice']).toBeDefined();
expect(result.ignoreList['Zane']).toBeDefined();
});
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('ADD_TO_IGNORE_LIST → inserts user into map', () => {
const state = makeServerState({ ignoreList: { Zane: makeUser({ name: 'Zane' }) } });
const result = serverReducer(state, Actions.addToIgnoreList({ user: makeUser({ name: 'Alice' }) }));
expect(result.ignoreList['Alice']).toBeDefined();
expect(Object.keys(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');
it('REMOVE_FROM_IGNORE_LIST → removes user by name from map', () => {
const state = makeServerState({
ignoreList: { Alice: makeUser({ name: 'Alice' }), Bob: makeUser({ name: 'Bob' }) },
});
const result = serverReducer(state, Actions.removeFromIgnoreList({ userName: 'Alice' }));
expect(result.ignoreList['Alice']).toBeUndefined();
expect(result.ignoreList['Bob']).toBeDefined();
});
});
@ -249,13 +252,13 @@ describe('Logs', () => {
it('VIEW_LOGS → groups LogItem[] into room/game/chat buckets', () => {
const log = makeLogItem({ targetType: 'room' });
const state = makeServerState();
const result = serverReducer(state, { type: Types.VIEW_LOGS, logs: [log] });
const result = serverReducer(state, Actions.viewLogs({ logs: [log] }));
expect(result.logs.room).toEqual([log]);
});
it('CLEAR_LOGS → resets logs to empty arrays', () => {
const state = makeServerState({ logs: { room: [makeLogItem()], game: [], chat: [] } });
const result = serverReducer(state, { type: Types.CLEAR_LOGS });
const result = serverReducer(state, Actions.clearLogs());
expect(result.logs.room).toEqual([]);
expect(result.logs.game).toEqual([]);
expect(result.logs.chat).toEqual([]);
@ -267,18 +270,18 @@ describe('Logs', () => {
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 });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hi' } as Data.Event_UserMessage;
const result = serverReducer(state, Actions.userMessage({ messageData }));
expect(result.messages['Bob']).toHaveLength(1);
expect(result.messages['Bob'][0]).toBe(messageData);
expect(result.messages['Bob'][0]).toEqual(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 });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'yo' } as Data.Event_UserMessage;
const result = serverReducer(state, Actions.userMessage({ messageData }));
expect(result.messages['Alice']).toHaveLength(1);
expect(result.messages['Alice'][0]).toBe(messageData);
expect(result.messages['Alice'][0]).toEqual(messageData);
});
it('USER_MESSAGE → appends to existing messages for that user', () => {
@ -288,7 +291,7 @@ describe('Messaging', () => {
messages: { Alice: [existingMsg] },
});
const newMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'second' });
const result = serverReducer(state, { type: Types.USER_MESSAGE, messageData: newMsg });
const result = serverReducer(state, Actions.userMessage({ messageData: newMsg }));
expect(result.messages['Alice']).toHaveLength(2);
});
});
@ -299,23 +302,23 @@ 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);
const result = serverReducer(state, Actions.getUserInfo({ userInfo }));
expect(result.userInfo['Eve']).toEqual(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 });
const notification = { type: 1, warningReason: '', customTitle: '', customContent: '' } as unknown as Data.Event_NotifyUser;
const result = serverReducer(state, Actions.notifyUser({ notification }));
expect(result.notifications).toHaveLength(1);
expect(result.notifications[0]).toBe(notification);
expect(result.notifications[0]).toEqual(notification);
});
it('SERVER_SHUTDOWN → sets serverShutdown to action.data', () => {
const data = { reason: 'maintenance', minutes: 10 };
it('SERVER_SHUTDOWN → sets serverShutdown to action.payload.data', () => {
const data = { reason: 'maintenance', minutes: 10 } as unknown as Data.Event_ServerShutdown;
const state = makeServerState();
const result = serverReducer(state, { type: Types.SERVER_SHUTDOWN, data });
expect(result.serverShutdown).toBe(data);
const result = serverReducer(state, Actions.serverShutdown({ data }));
expect(result.serverShutdown).toEqual(data);
});
});
@ -324,46 +327,46 @@ describe('User Info & Notifications', () => {
describe('Moderation', () => {
it('BAN_FROM_SERVER → sets banUser', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.BAN_FROM_SERVER, userName: 'Frank' });
const result = serverReducer(state, Actions.banFromServer({ 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);
const result = serverReducer(state, Actions.banHistory({ userName: 'Frank', banHistory: history }));
expect(result.banHistory['Frank']).toEqual(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);
const result = serverReducer(state, Actions.warnHistory({ userName: 'Grace', warnHistory: history }));
expect(result.warnHistory['Grace']).toEqual(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);
const result = serverReducer(state, Actions.warnListOptions({ warnList: list }));
expect(result.warnListOptions).toEqual(list);
});
it('WARN_USER → sets warnUser', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.WARN_USER, userName: 'Hank' });
const result = serverReducer(state, Actions.warnUser({ 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' });
const result = serverReducer(state, Actions.getAdminNotes({ 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' });
const result = serverReducer(state, Actions.updateAdminNotes({ userName: 'Ira', notes: 'new' }));
expect(result.adminNotes['Ira']).toBe('new');
});
});
@ -374,87 +377,108 @@ describe('ADJUST_MOD', () => {
const baseUserLevel = UserLevelFlag.IsUser | UserLevelFlag.IsRegistered | UserLevelFlag.IsModerator | UserLevelFlag.IsJudge;
it('shouldBeMod=true, shouldBeJudge=true → sets both bits, preserves IsUser|IsRegistered', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: true });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: true }));
// IsUser(1) | IsRegistered(2) | IsModerator(4) | IsJudge(16) = 23
expect(result.users[0].userLevel).toBe(23);
expect(result.users['Dan'].userLevel).toBe(23);
});
it('shouldBeMod=true, shouldBeJudge=false → sets IsModerator, clears IsJudge, preserves others', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: false });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: false }));
// IsUser(1) | IsRegistered(2) | IsModerator(4) = 7
expect(result.users[0].userLevel).toBe(7);
expect(result.users['Dan'].userLevel).toBe(7);
});
it('shouldBeMod=false, shouldBeJudge=true → clears IsModerator, sets IsJudge, preserves others', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: true });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: false, shouldBeJudge: true }));
// IsUser(1) | IsRegistered(2) | IsJudge(16) = 19
expect(result.users[0].userLevel).toBe(19);
expect(result.users['Dan'].userLevel).toBe(19);
});
it('shouldBeMod=false, shouldBeJudge=false → clears both bits, preserves IsUser|IsRegistered', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: baseUserLevel })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: false, shouldBeJudge: false });
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: false, shouldBeJudge: false }));
// IsUser(1) | IsRegistered(2) = 3
expect(result.users[0].userLevel).toBe(3);
expect(result.users['Dan'].userLevel).toBe(3);
});
it('shouldBeMod=true on IsUser|IsRegistered only → produces 7, not 4', () => {
const state = makeServerState({ users: [makeUser({ name: 'Dan', userLevel: UserLevelFlag.IsUser | UserLevelFlag.IsRegistered })] });
const result = serverReducer(state, { type: Types.ADJUST_MOD, userName: 'Dan', shouldBeMod: true, shouldBeJudge: false });
const state = makeServerState({
users: { Dan: makeUser({ name: 'Dan', userLevel: UserLevelFlag.IsUser | UserLevelFlag.IsRegistered }) },
});
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: true, shouldBeJudge: false }));
// IsUser(1) | IsRegistered(2) | IsModerator(4) = 7
expect(result.users[0].userLevel).toBe(7);
expect(result.users['Dan'].userLevel).toBe(7);
});
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);
const state = makeServerState({
users: { Alice: alice, Dan: makeUser({ name: 'Dan', userLevel: baseUserLevel }) },
});
const result = serverReducer(state, Actions.adjustMod({ userName: 'Dan', shouldBeMod: false, shouldBeJudge: false }));
expect(result.users['Alice']).toEqual(alice);
});
it('unknown userName → state unchanged', () => {
const state = makeServerState({ users: { Dan: makeUser({ name: 'Dan' }) } });
const result = serverReducer(state, Actions.adjustMod({ userName: 'Ghost', shouldBeMod: true, shouldBeJudge: false }));
expect(result).toEqual(state);
});
});
// ── Replays ───────────────────────────────────────────────────────────────────
describe('Replays', () => {
it('REPLAY_LIST → replaces replays list', () => {
it('REPLAY_LIST → replaces replays map keyed by gameId', () => {
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);
const state = makeServerState({ replays: { 99: makeReplayMatch({ gameId: 99 }) } });
const result = serverReducer(state, Actions.replayList({ matchList }));
expect(Object.keys(result.replays)).toHaveLength(1);
expect(result.replays[10]).toBeDefined();
expect(result.replays[99]).toBeUndefined();
});
it('REPLAY_ADDED → appends matchInfo to replays', () => {
it('REPLAY_ADDED → inserts matchInfo into replays map', () => {
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);
const state = makeServerState({ replays: { 1: existing } });
const result = serverReducer(state, Actions.replayAdded({ matchInfo: added }));
expect(Object.keys(result.replays)).toHaveLength(2);
expect(result.replays[2]).toEqual(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);
const state = makeServerState({ replays: { 5: makeReplayMatch({ gameId: 5, doNotHide: false }) } });
const result = serverReducer(state, Actions.replayModifyMatch({ gameId: 5, doNotHide: true }));
expect(result.replays[5].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);
const state = makeServerState({ replays: { 1: r1, 2: r2 } });
const result = serverReducer(state, Actions.replayModifyMatch({ gameId: 1, doNotHide: true }));
expect(result.replays[2]).toEqual(r2);
expect(result.replays[2].doNotHide).toBe(false);
});
it('REPLAY_MODIFY_MATCH → unknown gameId → state unchanged', () => {
const state = makeServerState({ replays: { 5: makeReplayMatch({ gameId: 5 }) } });
const result = serverReducer(state, Actions.replayModifyMatch({ gameId: 999, doNotHide: true }));
expect(result).toEqual(state);
});
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);
const state = makeServerState({
replays: { 5: makeReplayMatch({ gameId: 5 }), 6: makeReplayMatch({ gameId: 6 }) },
});
const result = serverReducer(state, Actions.replayDeleteMatch({ gameId: 5 }));
expect(Object.keys(result.replays)).toHaveLength(1);
expect(result.replays[5]).toBeUndefined();
expect(result.replays[6]).toBeDefined();
});
});
@ -464,22 +488,22 @@ 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);
const result = serverReducer(state, Actions.backendDecks({ deckList }));
expect(result.backendDecks).toEqual(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);
const result = serverReducer(state, Actions.deckUpload({ path: '', treeItem: makeDeckTreeItem() }));
expect(result).toEqual(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);
const result = serverReducer(state, Actions.deckUpload({ path: '', treeItem: item }));
expect(result.backendDecks!.root!.items).toHaveLength(1);
expect(result.backendDecks!.root!.items[0]).toEqual(item);
});
it('DECK_UPLOAD with nested path → inserts into matching subfolder', () => {
@ -490,25 +514,25 @@ describe('Deck Storage', () => {
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { 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);
const result = serverReducer(state, Actions.deckUpload({ 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]).toEqual(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);
const result = serverReducer(state, Actions.deckUpload({ 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]).toEqual(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);
const result = serverReducer(state, Actions.deckDelete({ deckId: 1 }));
expect(result).toEqual(state);
});
it('DECK_DELETE → removes item by id from tree', () => {
@ -516,8 +540,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [item] }) }),
});
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 7 });
expect(result.backendDecks.root.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelete({ deckId: 7 }));
expect(result.backendDecks!.root!.items).toHaveLength(0);
});
it('DECK_DELETE → recursively removes item nested inside a subfolder', () => {
@ -528,22 +552,22 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DELETE, deckId: 9 });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelete({ 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);
const result = serverReducer(state, Actions.deckNewDir({ path: '', dirName: 'newDir' }));
expect(result).toEqual(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.items).toEqual([]);
const result = serverReducer(state, Actions.deckNewDir({ 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!.items).toEqual([]);
});
it('DECK_NEW_DIR nested → inserts folder inside matching subfolder', () => {
@ -553,16 +577,16 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { 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');
const result = serverReducer(state, Actions.deckNewDir({ 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);
const result = serverReducer(state, Actions.deckDelDir({ path: 'myDir' }));
expect(result).toEqual(state);
});
it('DECK_DEL_DIR → removes folder from root by name', () => {
@ -572,8 +596,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'myDir' });
expect(result.backendDecks.root.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelDir({ path: 'myDir' }));
expect(result.backendDecks!.root!.items).toHaveLength(0);
});
it('DECK_DEL_DIR → returns deck tree unchanged when path is empty', () => {
@ -583,8 +607,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [subfolder] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: '' });
expect(result.backendDecks.root.items).toHaveLength(1);
const result = serverReducer(state, Actions.deckDelDir({ path: '' }));
expect(result.backendDecks!.root!.items).toHaveLength(1);
});
it('DECK_DEL_DIR → recursively removes nested subfolder via multi-segment path', () => {
@ -597,8 +621,8 @@ describe('Deck Storage', () => {
const state = makeServerState({
backendDecks: makeDeckList({ root: create(Data.ServerInfo_DeckStorage_FolderSchema, { items: [parent] }) })
});
const result = serverReducer(state, { type: Types.DECK_DEL_DIR, path: 'parent/child' });
expect(result.backendDecks.root.items[0].folder.items).toHaveLength(0);
const result = serverReducer(state, Actions.deckDelDir({ path: 'parent/child' }));
expect(result.backendDecks!.root!.items[0].folder!.items).toHaveLength(0);
});
});
@ -611,7 +635,7 @@ describe('GAMES_OF_USER', () => {
roomList: [],
});
const state = makeServerState();
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', response });
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 5 })]);
});
@ -622,7 +646,7 @@ describe('GAMES_OF_USER', () => {
roomList: [],
});
const state = makeServerState({ gamesOfUser: { alice: old } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', response });
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
expect(result.gamesOfUser['alice']).toEqual([makeGame({ gameId: 2 })]);
});
@ -630,7 +654,7 @@ describe('GAMES_OF_USER', () => {
const bobGames = [makeGame({ gameId: 3 })];
const response = create(Data.Response_GetGamesOfUserSchema, { gameList: [], roomList: [] });
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', response });
expect(result.gamesOfUser['bob']).toBe(bobGames);
const result = serverReducer(state, Actions.gamesOfUser({ userName: 'alice', response }));
expect(result.gamesOfUser['bob']).toEqual(bobGames);
});
});