Compare commits

...

7 commits

Author SHA1 Message Date
seavor
68e22d22bf migrate from CRA to vite 2026-04-12 18:35:13 -05:00
seavor
98ce317ee1 remove naked password from redux layer 2026-04-12 15:55:00 -05:00
seavor
559a3ff1f4 harden implementations 2026-04-12 15:21:29 -05:00
seavor
c3ae4cffd6 Fix various issues 2026-04-12 13:58:51 -05:00
seavor
3001925430 complete unit testing of redux and api layers 2026-04-12 12:53:51 -05:00
seavor
367852866f implement test coverage for game layer 2026-04-12 11:33:55 -05:00
seavor
74803442d2 Implement game layer from protobuf to redux 2026-04-12 05:05:16 -05:00
172 changed files with 13978 additions and 28444 deletions

View file

@ -30,7 +30,7 @@ jobs:
fail-fast: false
matrix:
node_version:
- 16
- 20
- lts/*
steps:

View file

@ -1 +1 @@
ESLINT_NO_DEV_ERRORS=true

View file

@ -1 +1 @@
DISABLE_ESLINT_PLUGIN=true

View file

@ -1 +1 @@
CI=true

View file

@ -1,7 +1,7 @@
module.exports = {
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {"project": ["./tsconfig.json"]},
"parserOptions": {"ecmaVersion": 2020, "sourceType": "module", "ecmaFeatures": {"jsx": true}},
"plugins": [
"@typescript-eslint"
],

22
webclient/index.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" type="text/css" href="/reset.css">
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Webatrice: A Cockatrice Web Client"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Webatrice</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

31974
webclient/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,13 +5,13 @@
"scripts": {
"prebuild": "node prebuild.js",
"prestart": "node prebuild.js",
"build": "react-scripts build",
"start": "react-scripts start",
"test": "react-scripts test",
"test:watch": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint \"./**/*.{ts,tsx}\"",
"lint:fix": "eslint \"./**/*.{ts,tsx}\" --fix",
"build": "vite build",
"start": "vite",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/**/*.{ts,tsx}",
"lint:fix": "eslint src/**/*.{ts,tsx} --fix",
"golden": "npm run lint && npm run test",
"prepare": "cd .. && husky install",
"translate": "node prebuild.js -i18nOnly"
@ -23,6 +23,7 @@
"@mui/material": "^5.5.1",
"crypto-js": "^4.2.0",
"dexie": "^3.2.2",
"dompurify": "^3.3.3",
"final-form": "^4.20.6",
"final-form-set-field-touched": "^1.0.1",
"i18next": "^22.0.4",
@ -39,21 +40,18 @@
"react-i18next": "^12.0.0",
"react-redux": "^8.0.4",
"react-router-dom": "^6.2.2",
"react-scripts": "5.0.1",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"redux": "^4.1.2",
"redux-form": "^8.3.8",
"redux-thunk": "^2.4.1",
"rxjs": "^7.5.4",
"sanitize-html": "^2.7.3"
"rxjs": "^7.5.4"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@mui/types": "^7.1.3",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^13.4.0",
"@types/jest": "29.2.0",
"@types/dompurify": "^3.0.5",
"@types/jquery": "^3.5.14",
"@types/lodash": "^4.14.179",
"@types/node": "18.11.7",
@ -67,12 +65,16 @@
"@types/redux-form": "^8.3.3",
"@typescript-eslint/eslint-plugin": "^5.14.0",
"@typescript-eslint/parser": "^5.14.0",
"@vitejs/plugin-react": "^4.2.0",
"@vitest/coverage-v8": "^1.3.0",
"eslint": "^8.0.0",
"fs-extra": "^10.0.1",
"husky": "^8.0.1",
"typescript": "^4.6.2"
},
"eslintConfig": {
"extends": "react-app"
"jsdom": "^24.0.0",
"typescript": "^4.6.2",
"vite": "^5.1.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.3.0"
},
"browserslist": {
"production": [
@ -85,10 +87,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"moduleNameMapper": {
"\\.(css|less)$": "identity-obj-proxy"
}
}
}

View file

@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="stylesheet" type="text/css" href="%PUBLIC_URL%/reset.css">
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Webatrice: A Cockatrice Web Client"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Webatrice</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "Webatrice",
"name": "Webatrice",
"icons": [
{
"src": "favicon.ico",

View file

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

View file

@ -0,0 +1,145 @@
vi.mock('websocket', () => ({
SessionCommands: {
connect: vi.fn(),
disconnect: vi.fn(),
},
webClient: {
connectionAttemptMade: false,
},
}));
vi.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(() => vi.clearAllMocks());
describe('AuthenticationService', () => {
describe('login', () => {
it('calls SessionCommands.connect with LOGIN reason', () => {
AuthenticationService.login(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.LOGIN);
});
});
describe('testConnection', () => {
it('calls SessionCommands.connect with TEST_CONNECTION reason', () => {
AuthenticationService.testConnection(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.TEST_CONNECTION);
});
});
describe('register', () => {
it('calls SessionCommands.connect with REGISTER reason', () => {
AuthenticationService.register(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.REGISTER);
});
});
describe('activateAccount', () => {
it('calls SessionCommands.connect with ACTIVATE_ACCOUNT reason', () => {
AuthenticationService.activateAccount(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.ACTIVATE_ACCOUNT);
});
});
describe('resetPasswordRequest', () => {
it('calls SessionCommands.connect with PASSWORD_RESET_REQUEST reason', () => {
AuthenticationService.resetPasswordRequest(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET_REQUEST);
});
});
describe('resetPasswordChallenge', () => {
it('calls SessionCommands.connect with PASSWORD_RESET_CHALLENGE reason', () => {
AuthenticationService.resetPasswordChallenge(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
});
});
describe('resetPassword', () => {
it('calls SessionCommands.connect with PASSWORD_RESET reason', () => {
AuthenticationService.resetPassword(testOptions);
expect(SessionCommands.connect).toHaveBeenCalledWith(testOptions, WebSocketConnectReason.PASSWORD_RESET);
});
});
describe('disconnect', () => {
it('delegates to SessionCommands.disconnect', () => {
AuthenticationService.disconnect();
expect(SessionCommands.disconnect).toHaveBeenCalled();
});
});
describe('isConnected', () => {
it('returns true when state is LOGGED_IN', () => {
expect(AuthenticationService.isConnected(StatusEnum.LOGGED_IN)).toBe(true);
});
it('returns false when state is DISCONNECTED', () => {
expect(AuthenticationService.isConnected(StatusEnum.DISCONNECTED)).toBe(false);
});
it('returns false when state is CONNECTING', () => {
expect(AuthenticationService.isConnected(StatusEnum.CONNECTING)).toBe(false);
});
it('returns false when state is CONNECTED', () => {
expect(AuthenticationService.isConnected(StatusEnum.CONNECTED)).toBe(false);
});
it('returns false when state is LOGGING_IN', () => {
expect(AuthenticationService.isConnected(StatusEnum.LOGGING_IN)).toBe(false);
});
});
describe('isModerator', () => {
it('returns true when userLevel has the IsModerator bit set', () => {
expect(AuthenticationService.isModerator({ userLevel: 4 } as any)).toBe(true);
});
it('returns true when userLevel has IsModerator and other bits set', () => {
expect(AuthenticationService.isModerator({ userLevel: 7 } as any)).toBe(true);
});
it('returns false when userLevel does not have the IsModerator bit', () => {
expect(AuthenticationService.isModerator({ userLevel: 1 } as any)).toBe(false);
});
it('returns false for admin-only userLevel without moderator bit', () => {
expect(AuthenticationService.isModerator({ userLevel: 8 } as any)).toBe(false);
});
});
describe('isAdmin', () => {
it('returns undefined (not yet implemented)', () => {
expect(AuthenticationService.isAdmin()).toBeUndefined();
});
});
describe('connectionAttemptMade', () => {
it('returns webClient.connectionAttemptMade when false', () => {
(webClient as any).connectionAttemptMade = false;
expect(AuthenticationService.connectionAttemptMade()).toBe(false);
});
it('returns webClient.connectionAttemptMade when true', () => {
(webClient as any).connectionAttemptMade = true;
expect(AuthenticationService.connectionAttemptMade()).toBe(true);
});
});
});

View file

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

View file

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

View file

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

View file

@ -64,11 +64,13 @@ const Root = styled('div')(({ theme }) => ({
}
}));
const Login = ({ state, description, connectOptions }: LoginProps) => {
const Login = ({ state, description }: LoginProps) => {
const { t } = useTranslation();
const isConnected = AuthenticationService.isConnected(state);
const [pendingActivationOptions, setPendingActivationOptions] = useState<WebSocketConnectOptions | null>(null);
const [rememberLogin, setRememberLogin] = useState(null);
const [dialogState, setDialogState] = useState({
passwordResetRequestDialog: false,
@ -97,9 +99,11 @@ const Login = ({ state, description, connectOptions }: LoginProps) => {
useReduxEffect(() => {
accountActivatedToast.openToast()
closeActivateAccountDialog();
setPendingActivationOptions(null);
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
useReduxEffect(() => {
useReduxEffect(({ options }) => {
setPendingActivationOptions(options);
closeRegistrationDialog();
openActivateAccountDialog();
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
@ -161,7 +165,7 @@ const Login = ({ state, description, connectOptions }: LoginProps) => {
const handleAccountActivationDialogSubmit = ({ token }) => {
AuthenticationService.activateAccount({
...connectOptions,
...pendingActivationOptions,
token,
});
};
@ -348,13 +352,11 @@ const Login = ({ state, description, connectOptions }: LoginProps) => {
interface LoginProps {
state: number;
description: string;
connectOptions: WebSocketConnectOptions;
}
const mapStateToProps = state => ({
state: ServerSelectors.getState(state),
description: ServerSelectors.getDescription(state),
connectOptions: ServerSelectors.getConnectOptions(state),
});
export default connect(mapStateToProps)(Login);

View file

@ -38,7 +38,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
useReduxEffect(() => {
openToast()
}, ServerTypes.REGISTRATION_SUCCES);
}, ServerTypes.REGISTRATION_SUCCESS);
useReduxEffect(({ error }) => {
setEmailError(error);

View file

@ -10,7 +10,7 @@ import { useFireOnce } from './useFireOnce';
describe('useFireOnce hook', () => {
test('it only fires once when button is clicked twice', async () => {
// Mock a promise with a delay
const onClickWithPromise = jest.fn((e) => {
const onClickWithPromise = vi.fn((e) => {
e.preventDefault()
return new Promise((resolve) => {
setTimeout(() => {
@ -54,7 +54,7 @@ describe('useFireOnce hook', () => {
test('it only fires once when form is submitted twice', async () => {
// Mock a promise with a delay
const onClickWithPromise = jest.fn((e) => {
const onClickWithPromise = vi.fn((e) => {
e.preventDefault()
return new Promise((resolve) => {
setTimeout(() => {

View file

@ -4,7 +4,7 @@ import { Language } from 'types';
class I18nBackend {
static type: ModuleType = 'backend';
static BASE_URL = `${process.env.PUBLIC_URL}/locales`;
static BASE_URL = `${import.meta.env.BASE_URL}locales`;
read(language, namespace, callback) {
if (!Language[language]) {

View file

@ -1,256 +1,254 @@
// Remove !file-loader! once the following is no longer an issue
// https://github.com/facebook/create-react-app/issues/11770
import ad from '!file-loader!./ad.svg';
import ae from '!file-loader!./ae.svg';
import af from '!file-loader!./af.svg';
import ag from '!file-loader!./ag.svg';
import ai from '!file-loader!./ai.svg';
import al from '!file-loader!./al.svg';
import am from '!file-loader!./am.svg';
import ao from '!file-loader!./ao.svg';
import aq from '!file-loader!./aq.svg';
import ar from '!file-loader!./ar.svg';
import as from '!file-loader!./as.svg';
import at from '!file-loader!./at.svg';
import au from '!file-loader!./au.svg';
import aw from '!file-loader!./aw.svg';
import ax from '!file-loader!./ax.svg';
import az from '!file-loader!./az.svg';
import ba from '!file-loader!./ba.svg';
import bb from '!file-loader!./bb.svg';
import bd from '!file-loader!./bd.svg';
import be from '!file-loader!./be.svg';
import bf from '!file-loader!./bf.svg';
import bg from '!file-loader!./bg.svg';
import bh from '!file-loader!./bh.svg';
import bi from '!file-loader!./bi.svg';
import bj from '!file-loader!./bj.svg';
import bl from '!file-loader!./bl.svg';
import bm from '!file-loader!./bm.svg';
import bn from '!file-loader!./bn.svg';
import bo from '!file-loader!./bo.svg';
import bq from '!file-loader!./bq.svg';
import br from '!file-loader!./br.svg';
import bs from '!file-loader!./bs.svg';
import bt from '!file-loader!./bt.svg';
import bv from '!file-loader!./bv.svg';
import bw from '!file-loader!./bw.svg';
import by from '!file-loader!./by.svg';
import bz from '!file-loader!./bz.svg';
import ca from '!file-loader!./ca.svg';
import cc from '!file-loader!./cc.svg';
import cd from '!file-loader!./cd.svg';
import cf from '!file-loader!./cf.svg';
import cg from '!file-loader!./cg.svg';
import ch from '!file-loader!./ch.svg';
import ci from '!file-loader!./ci.svg';
import ck from '!file-loader!./ck.svg';
import cl from '!file-loader!./cl.svg';
import cm from '!file-loader!./cm.svg';
import cn from '!file-loader!./cn.svg';
import co from '!file-loader!./co.svg';
import cr from '!file-loader!./cr.svg';
import cu from '!file-loader!./cu.svg';
import cv from '!file-loader!./cv.svg';
import cw from '!file-loader!./cw.svg';
import cx from '!file-loader!./cx.svg';
import cy from '!file-loader!./cy.svg';
import cz from '!file-loader!./cz.svg';
import de from '!file-loader!./de.svg';
import dj from '!file-loader!./dj.svg';
import dk from '!file-loader!./dk.svg';
import dm from '!file-loader!./dm.svg';
import _do from '!file-loader!./do.svg';
import dz from '!file-loader!./dz.svg';
import ec from '!file-loader!./ec.svg';
import ee from '!file-loader!./ee.svg';
import eg from '!file-loader!./eg.svg';
import eh from '!file-loader!./eh.svg';
import er from '!file-loader!./er.svg';
import es from '!file-loader!./es.svg';
import et from '!file-loader!./et.svg';
import eu from '!file-loader!./eu.svg';
import fi from '!file-loader!./fi.svg';
import fj from '!file-loader!./fj.svg';
import fk from '!file-loader!./fk.svg';
import fm from '!file-loader!./fm.svg';
import fo from '!file-loader!./fo.svg';
import fr from '!file-loader!./fr.svg';
import ga from '!file-loader!./ga.svg';
import gb from '!file-loader!./gb.svg';
import gd from '!file-loader!./gd.svg';
import ge from '!file-loader!./ge.svg';
import gf from '!file-loader!./gf.svg';
import gg from '!file-loader!./gg.svg';
import gh from '!file-loader!./gh.svg';
import gi from '!file-loader!./gi.svg';
import gl from '!file-loader!./gl.svg';
import gm from '!file-loader!./gm.svg';
import gn from '!file-loader!./gn.svg';
import gp from '!file-loader!./gp.svg';
import gq from '!file-loader!./gq.svg';
import gr from '!file-loader!./gr.svg';
import gs from '!file-loader!./gs.svg';
import gt from '!file-loader!./gt.svg';
import gu from '!file-loader!./gu.svg';
import gw from '!file-loader!./gw.svg';
import gy from '!file-loader!./gy.svg';
import hk from '!file-loader!./hk.svg';
import hm from '!file-loader!./hm.svg';
import hn from '!file-loader!./hn.svg';
import hr from '!file-loader!./hr.svg';
import ht from '!file-loader!./ht.svg';
import hu from '!file-loader!./hu.svg';
import id from '!file-loader!./id.svg';
import ie from '!file-loader!./ie.svg';
import il from '!file-loader!./il.svg';
import im from '!file-loader!./im.svg';
import _in from '!file-loader!./in.svg';
import io from '!file-loader!./io.svg';
import iq from '!file-loader!./iq.svg';
import ir from '!file-loader!./ir.svg';
import is from '!file-loader!./is.svg';
import it from '!file-loader!./it.svg';
import je from '!file-loader!./je.svg';
import jm from '!file-loader!./jm.svg';
import jo from '!file-loader!./jo.svg';
import jp from '!file-loader!./jp.svg';
import ke from '!file-loader!./ke.svg';
import kg from '!file-loader!./kg.svg';
import kh from '!file-loader!./kh.svg';
import ki from '!file-loader!./ki.svg';
import km from '!file-loader!./km.svg';
import kn from '!file-loader!./kn.svg';
import kp from '!file-loader!./kp.svg';
import kr from '!file-loader!./kr.svg';
import kw from '!file-loader!./kw.svg';
import ky from '!file-loader!./ky.svg';
import kz from '!file-loader!./kz.svg';
import la from '!file-loader!./la.svg';
import lb from '!file-loader!./lb.svg';
import lc from '!file-loader!./lc.svg';
import li from '!file-loader!./li.svg';
import lk from '!file-loader!./lk.svg';
import lr from '!file-loader!./lr.svg';
import ls from '!file-loader!./ls.svg';
import lt from '!file-loader!./lt.svg';
import lu from '!file-loader!./lu.svg';
import lv from '!file-loader!./lv.svg';
import ly from '!file-loader!./ly.svg';
import ma from '!file-loader!./ma.svg';
import mc from '!file-loader!./mc.svg';
import md from '!file-loader!./md.svg';
import me from '!file-loader!./me.svg';
import mf from '!file-loader!./mf.svg';
import mg from '!file-loader!./mg.svg';
import mh from '!file-loader!./mh.svg';
import mk from '!file-loader!./mk.svg';
import ml from '!file-loader!./ml.svg';
import mm from '!file-loader!./mm.svg';
import mn from '!file-loader!./mn.svg';
import mo from '!file-loader!./mo.svg';
import mp from '!file-loader!./mp.svg';
import mq from '!file-loader!./mq.svg';
import mr from '!file-loader!./mr.svg';
import ms from '!file-loader!./ms.svg';
import mt from '!file-loader!./mt.svg';
import mu from '!file-loader!./mu.svg';
import mv from '!file-loader!./mv.svg';
import mw from '!file-loader!./mw.svg';
import mx from '!file-loader!./mx.svg';
import my from '!file-loader!./my.svg';
import mz from '!file-loader!./mz.svg';
import na from '!file-loader!./na.svg';
import nc from '!file-loader!./nc.svg';
import ne from '!file-loader!./ne.svg';
import nf from '!file-loader!./nf.svg';
import ng from '!file-loader!./ng.svg';
import ni from '!file-loader!./ni.svg';
import nl from '!file-loader!./nl.svg';
import no from '!file-loader!./no.svg';
import np from '!file-loader!./np.svg';
import nr from '!file-loader!./nr.svg';
import nu from '!file-loader!./nu.svg';
import nz from '!file-loader!./nz.svg';
import om from '!file-loader!./om.svg';
import pa from '!file-loader!./pa.svg';
import pe from '!file-loader!./pe.svg';
import pf from '!file-loader!./pf.svg';
import pg from '!file-loader!./pg.svg';
import ph from '!file-loader!./ph.svg';
import pk from '!file-loader!./pk.svg';
import pl from '!file-loader!./pl.svg';
import pm from '!file-loader!./pm.svg';
import pn from '!file-loader!./pn.svg';
import pr from '!file-loader!./pr.svg';
import ps from '!file-loader!./ps.svg';
import pt from '!file-loader!./pt.svg';
import pw from '!file-loader!./pw.svg';
import py from '!file-loader!./py.svg';
import qa from '!file-loader!./qa.svg';
import re from '!file-loader!./re.svg';
import ro from '!file-loader!./ro.svg';
import rs from '!file-loader!./rs.svg';
import ru from '!file-loader!./ru.svg';
import rw from '!file-loader!./rw.svg';
import sa from '!file-loader!./sa.svg';
import sb from '!file-loader!./sb.svg';
import sc from '!file-loader!./sc.svg';
import sd from '!file-loader!./sd.svg';
import se from '!file-loader!./se.svg';
import sg from '!file-loader!./sg.svg';
import sh from '!file-loader!./sh.svg';
import si from '!file-loader!./si.svg';
import sj from '!file-loader!./sj.svg';
import sk from '!file-loader!./sk.svg';
import sl from '!file-loader!./sl.svg';
import sm from '!file-loader!./sm.svg';
import sn from '!file-loader!./sn.svg';
import so from '!file-loader!./so.svg';
import sr from '!file-loader!./sr.svg';
import ss from '!file-loader!./ss.svg';
import st from '!file-loader!./st.svg';
import sv from '!file-loader!./sv.svg';
import sx from '!file-loader!./sx.svg';
import sy from '!file-loader!./sy.svg';
import sz from '!file-loader!./sz.svg';
import tc from '!file-loader!./tc.svg';
import td from '!file-loader!./td.svg';
import tf from '!file-loader!./tf.svg';
import tg from '!file-loader!./tg.svg';
import th from '!file-loader!./th.svg';
import tj from '!file-loader!./tj.svg';
import tk from '!file-loader!./tk.svg';
import tl from '!file-loader!./tl.svg';
import tm from '!file-loader!./tm.svg';
import tn from '!file-loader!./tn.svg';
import to from '!file-loader!./to.svg';
import tr from '!file-loader!./tr.svg';
import tt from '!file-loader!./tt.svg';
import tv from '!file-loader!./tv.svg';
import tw from '!file-loader!./tw.svg';
import tz from '!file-loader!./tz.svg';
import ua from '!file-loader!./ua.svg';
import ug from '!file-loader!./ug.svg';
import um from '!file-loader!./um.svg';
import us from '!file-loader!./us.svg';
import uy from '!file-loader!./uy.svg';
import uz from '!file-loader!./uz.svg';
import va from '!file-loader!./va.svg';
import vc from '!file-loader!./vc.svg';
import ve from '!file-loader!./ve.svg';
import vg from '!file-loader!./vg.svg';
import vi from '!file-loader!./vi.svg';
import vn from '!file-loader!./vn.svg';
import vu from '!file-loader!./vu.svg';
import wf from '!file-loader!./wf.svg';
import ws from '!file-loader!./ws.svg';
import xk from '!file-loader!./xk.svg';
import ye from '!file-loader!./ye.svg';
import yt from '!file-loader!./yt.svg';
import za from '!file-loader!./za.svg';
import zm from '!file-loader!./zm.svg';
import zw from '!file-loader!./zw.svg';
import ad from './ad.svg';
import ae from './ae.svg';
import af from './af.svg';
import ag from './ag.svg';
import ai from './ai.svg';
import al from './al.svg';
import am from './am.svg';
import ao from './ao.svg';
import aq from './aq.svg';
import ar from './ar.svg';
import as from './as.svg';
import at from './at.svg';
import au from './au.svg';
import aw from './aw.svg';
import ax from './ax.svg';
import az from './az.svg';
import ba from './ba.svg';
import bb from './bb.svg';
import bd from './bd.svg';
import be from './be.svg';
import bf from './bf.svg';
import bg from './bg.svg';
import bh from './bh.svg';
import bi from './bi.svg';
import bj from './bj.svg';
import bl from './bl.svg';
import bm from './bm.svg';
import bn from './bn.svg';
import bo from './bo.svg';
import bq from './bq.svg';
import br from './br.svg';
import bs from './bs.svg';
import bt from './bt.svg';
import bv from './bv.svg';
import bw from './bw.svg';
import by from './by.svg';
import bz from './bz.svg';
import ca from './ca.svg';
import cc from './cc.svg';
import cd from './cd.svg';
import cf from './cf.svg';
import cg from './cg.svg';
import ch from './ch.svg';
import ci from './ci.svg';
import ck from './ck.svg';
import cl from './cl.svg';
import cm from './cm.svg';
import cn from './cn.svg';
import co from './co.svg';
import cr from './cr.svg';
import cu from './cu.svg';
import cv from './cv.svg';
import cw from './cw.svg';
import cx from './cx.svg';
import cy from './cy.svg';
import cz from './cz.svg';
import de from './de.svg';
import dj from './dj.svg';
import dk from './dk.svg';
import dm from './dm.svg';
import _do from './do.svg';
import dz from './dz.svg';
import ec from './ec.svg';
import ee from './ee.svg';
import eg from './eg.svg';
import eh from './eh.svg';
import er from './er.svg';
import es from './es.svg';
import et from './et.svg';
import eu from './eu.svg';
import fi from './fi.svg';
import fj from './fj.svg';
import fk from './fk.svg';
import fm from './fm.svg';
import fo from './fo.svg';
import fr from './fr.svg';
import ga from './ga.svg';
import gb from './gb.svg';
import gd from './gd.svg';
import ge from './ge.svg';
import gf from './gf.svg';
import gg from './gg.svg';
import gh from './gh.svg';
import gi from './gi.svg';
import gl from './gl.svg';
import gm from './gm.svg';
import gn from './gn.svg';
import gp from './gp.svg';
import gq from './gq.svg';
import gr from './gr.svg';
import gs from './gs.svg';
import gt from './gt.svg';
import gu from './gu.svg';
import gw from './gw.svg';
import gy from './gy.svg';
import hk from './hk.svg';
import hm from './hm.svg';
import hn from './hn.svg';
import hr from './hr.svg';
import ht from './ht.svg';
import hu from './hu.svg';
import id from './id.svg';
import ie from './ie.svg';
import il from './il.svg';
import im from './im.svg';
import _in from './in.svg';
import io from './io.svg';
import iq from './iq.svg';
import ir from './ir.svg';
import is from './is.svg';
import it from './it.svg';
import je from './je.svg';
import jm from './jm.svg';
import jo from './jo.svg';
import jp from './jp.svg';
import ke from './ke.svg';
import kg from './kg.svg';
import kh from './kh.svg';
import ki from './ki.svg';
import km from './km.svg';
import kn from './kn.svg';
import kp from './kp.svg';
import kr from './kr.svg';
import kw from './kw.svg';
import ky from './ky.svg';
import kz from './kz.svg';
import la from './la.svg';
import lb from './lb.svg';
import lc from './lc.svg';
import li from './li.svg';
import lk from './lk.svg';
import lr from './lr.svg';
import ls from './ls.svg';
import lt from './lt.svg';
import lu from './lu.svg';
import lv from './lv.svg';
import ly from './ly.svg';
import ma from './ma.svg';
import mc from './mc.svg';
import md from './md.svg';
import me from './me.svg';
import mf from './mf.svg';
import mg from './mg.svg';
import mh from './mh.svg';
import mk from './mk.svg';
import ml from './ml.svg';
import mm from './mm.svg';
import mn from './mn.svg';
import mo from './mo.svg';
import mp from './mp.svg';
import mq from './mq.svg';
import mr from './mr.svg';
import ms from './ms.svg';
import mt from './mt.svg';
import mu from './mu.svg';
import mv from './mv.svg';
import mw from './mw.svg';
import mx from './mx.svg';
import my from './my.svg';
import mz from './mz.svg';
import na from './na.svg';
import nc from './nc.svg';
import ne from './ne.svg';
import nf from './nf.svg';
import ng from './ng.svg';
import ni from './ni.svg';
import nl from './nl.svg';
import no from './no.svg';
import np from './np.svg';
import nr from './nr.svg';
import nu from './nu.svg';
import nz from './nz.svg';
import om from './om.svg';
import pa from './pa.svg';
import pe from './pe.svg';
import pf from './pf.svg';
import pg from './pg.svg';
import ph from './ph.svg';
import pk from './pk.svg';
import pl from './pl.svg';
import pm from './pm.svg';
import pn from './pn.svg';
import pr from './pr.svg';
import ps from './ps.svg';
import pt from './pt.svg';
import pw from './pw.svg';
import py from './py.svg';
import qa from './qa.svg';
import re from './re.svg';
import ro from './ro.svg';
import rs from './rs.svg';
import ru from './ru.svg';
import rw from './rw.svg';
import sa from './sa.svg';
import sb from './sb.svg';
import sc from './sc.svg';
import sd from './sd.svg';
import se from './se.svg';
import sg from './sg.svg';
import sh from './sh.svg';
import si from './si.svg';
import sj from './sj.svg';
import sk from './sk.svg';
import sl from './sl.svg';
import sm from './sm.svg';
import sn from './sn.svg';
import so from './so.svg';
import sr from './sr.svg';
import ss from './ss.svg';
import st from './st.svg';
import sv from './sv.svg';
import sx from './sx.svg';
import sy from './sy.svg';
import sz from './sz.svg';
import tc from './tc.svg';
import td from './td.svg';
import tf from './tf.svg';
import tg from './tg.svg';
import th from './th.svg';
import tj from './tj.svg';
import tk from './tk.svg';
import tl from './tl.svg';
import tm from './tm.svg';
import tn from './tn.svg';
import to from './to.svg';
import tr from './tr.svg';
import tt from './tt.svg';
import tv from './tv.svg';
import tw from './tw.svg';
import tz from './tz.svg';
import ua from './ua.svg';
import ug from './ug.svg';
import um from './um.svg';
import us from './us.svg';
import uy from './uy.svg';
import uz from './uz.svg';
import va from './va.svg';
import vc from './vc.svg';
import ve from './ve.svg';
import vg from './vg.svg';
import vi from './vi.svg';
import vn from './vn.svg';
import vu from './vu.svg';
import wf from './wf.svg';
import ws from './ws.svg';
import xk from './xk.svg';
import ye from './ye.svg';
import yt from './yt.svg';
import za from './za.svg';
import zm from './zm.svg';
import zw from './zw.svg';
export const Countries = {
ad,

View file

@ -1 +0,0 @@
/// <reference types="react-scripts" />

View file

@ -1,7 +1,7 @@
import protobuf from 'protobufjs';
// ensure jest-dom is always available during testing to cut down on boilerplate
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/vitest';
class MockProtobufRoot {
load() {}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,124 @@
import { ArrowInfo, CardInfo, CounterInfo, PlayerProperties } from 'types';
import { GameEntry, GamesState, PlayerEntry, ZoneEntry } from '../game.interfaces';
export function makeCard(overrides: Partial<CardInfo> = {}): CardInfo {
return {
id: 1,
name: 'Test Card',
x: 0,
y: 0,
faceDown: false,
tapped: false,
attacking: false,
color: '',
pt: '',
annotation: '',
destroyOnZoneChange: false,
doesntUntap: false,
counterList: [],
attachPlayerId: -1,
attachZone: '',
attachCardId: -1,
providerId: '',
...overrides,
};
}
export function makeCounter(overrides: Partial<CounterInfo> = {}): CounterInfo {
return {
id: 1,
name: 'Life',
counterColor: { r: 0, g: 0, b: 0, a: 255 },
radius: 1,
count: 20,
...overrides,
};
}
export function makeArrow(overrides: Partial<ArrowInfo> = {}): ArrowInfo {
return {
id: 1,
startPlayerId: 1,
startZone: 'table',
startCardId: 1,
targetPlayerId: 1,
targetZone: 'table',
targetCardId: 2,
arrowColor: { r: 255, g: 0, b: 0, a: 255 },
...overrides,
};
}
export function makeZoneEntry(overrides: Partial<ZoneEntry> = {}): ZoneEntry {
return {
name: 'hand',
type: 1,
withCoords: false,
cardCount: 0,
cards: [],
alwaysRevealTopCard: false,
alwaysLookAtTopCard: false,
...overrides,
};
}
export function makePlayerProperties(overrides: Partial<PlayerProperties> = {}): PlayerProperties {
return {
playerId: 1,
userInfo: null,
spectator: false,
conceded: false,
readyStart: false,
deckHash: '',
pingSeconds: 0,
sideboardLocked: false,
judge: false,
...overrides,
};
}
export function makePlayerEntry(overrides: Partial<PlayerEntry> = {}): PlayerEntry {
return {
properties: makePlayerProperties(),
deckList: '',
zones: {
hand: makeZoneEntry({ name: 'hand' }),
deck: makeZoneEntry({ name: 'deck' }),
},
counters: {},
arrows: {},
...overrides,
};
}
export function makeGameEntry(overrides: Partial<GameEntry> = {}): GameEntry {
return {
gameId: 1,
roomId: 1,
description: 'Test Game',
hostId: 1,
localPlayerId: 1,
spectator: false,
judge: false,
resuming: false,
started: false,
activePlayerId: 0,
activePhase: 0,
secondsElapsed: 0,
reversed: false,
players: {
1: makePlayerEntry(),
},
messages: [],
...overrides,
};
}
export function makeState(overrides: Partial<GamesState> = {}): GamesState {
return {
games: {
1: makeGameEntry(),
},
...overrides,
};
}

View file

@ -0,0 +1,175 @@
import { Actions } from './game.actions';
import { Types } from './game.types';
import {
makeArrow,
makeCard,
makeCounter,
makeGameEntry,
makePlayerProperties,
makeZoneEntry,
} from './__mocks__/fixtures';
describe('Actions', () => {
it('clearStore', () => {
expect(Actions.clearStore()).toEqual({ type: Types.CLEAR_STORE });
});
it('gameJoined', () => {
const entry = makeGameEntry();
expect(Actions.gameJoined(1, entry)).toEqual({ type: Types.GAME_JOINED, gameId: 1, gameEntry: entry });
});
it('gameLeft', () => {
expect(Actions.gameLeft(2)).toEqual({ type: Types.GAME_LEFT, gameId: 2 });
});
it('gameClosed', () => {
expect(Actions.gameClosed(3)).toEqual({ type: Types.GAME_CLOSED, gameId: 3 });
});
it('gameHostChanged', () => {
expect(Actions.gameHostChanged(1, 7)).toEqual({ type: Types.GAME_HOST_CHANGED, gameId: 1, hostId: 7 });
});
it('gameStateChanged', () => {
const data = { playerList: [], gameStarted: true, activePlayerId: 1, activePhase: 0, secondsElapsed: 0 };
expect(Actions.gameStateChanged(1, data)).toEqual({ type: Types.GAME_STATE_CHANGED, gameId: 1, data });
});
it('playerJoined', () => {
const props = makePlayerProperties();
expect(Actions.playerJoined(1, props)).toEqual({ type: Types.PLAYER_JOINED, gameId: 1, playerProperties: props });
});
it('playerLeft', () => {
expect(Actions.playerLeft(1, 2, 3)).toEqual({ type: Types.PLAYER_LEFT, gameId: 1, playerId: 2, reason: 3 });
});
it('playerPropertiesChanged', () => {
const props = makePlayerProperties();
expect(Actions.playerPropertiesChanged(1, 2, props)).toEqual({
type: Types.PLAYER_PROPERTIES_CHANGED,
gameId: 1,
playerId: 2,
properties: props,
});
});
it('kicked', () => {
expect(Actions.kicked(1)).toEqual({ type: Types.KICKED, gameId: 1 });
});
it('cardMoved', () => {
const data = { cardId: 1 } as any;
expect(Actions.cardMoved(1, 2, data)).toEqual({ type: Types.CARD_MOVED, gameId: 1, playerId: 2, data });
});
it('cardFlipped', () => {
const data = { cardId: 1 } as any;
expect(Actions.cardFlipped(1, 2, data)).toEqual({ type: Types.CARD_FLIPPED, gameId: 1, playerId: 2, data });
});
it('cardDestroyed', () => {
const data = { cardId: 1 } as any;
expect(Actions.cardDestroyed(1, 2, data)).toEqual({ type: Types.CARD_DESTROYED, gameId: 1, playerId: 2, data });
});
it('cardAttached', () => {
const data = { cardId: 1 } as any;
expect(Actions.cardAttached(1, 2, data)).toEqual({ type: Types.CARD_ATTACHED, gameId: 1, playerId: 2, data });
});
it('tokenCreated', () => {
const data = { cardId: 1 } as any;
expect(Actions.tokenCreated(1, 2, data)).toEqual({ type: Types.TOKEN_CREATED, gameId: 1, playerId: 2, data });
});
it('cardAttrChanged', () => {
const data = { cardId: 1 } as any;
expect(Actions.cardAttrChanged(1, 2, data)).toEqual({ type: Types.CARD_ATTR_CHANGED, gameId: 1, playerId: 2, data });
});
it('cardCounterChanged', () => {
const data = { cardId: 1 } as any;
expect(Actions.cardCounterChanged(1, 2, data)).toEqual({ type: Types.CARD_COUNTER_CHANGED, gameId: 1, playerId: 2, data });
});
it('arrowCreated', () => {
const arrow = makeArrow();
const data = { arrowInfo: arrow };
expect(Actions.arrowCreated(1, 2, data)).toEqual({ type: Types.ARROW_CREATED, gameId: 1, playerId: 2, data });
});
it('arrowDeleted', () => {
const data = { arrowId: 3 };
expect(Actions.arrowDeleted(1, 2, data)).toEqual({ type: Types.ARROW_DELETED, gameId: 1, playerId: 2, data });
});
it('counterCreated', () => {
const counter = makeCounter();
const data = { counterInfo: counter };
expect(Actions.counterCreated(1, 2, data)).toEqual({ type: Types.COUNTER_CREATED, gameId: 1, playerId: 2, data });
});
it('counterSet', () => {
const data = { counterId: 1, value: 10 };
expect(Actions.counterSet(1, 2, data)).toEqual({ type: Types.COUNTER_SET, gameId: 1, playerId: 2, data });
});
it('counterDeleted', () => {
const data = { counterId: 1 };
expect(Actions.counterDeleted(1, 2, data)).toEqual({ type: Types.COUNTER_DELETED, gameId: 1, playerId: 2, data });
});
it('cardsDrawn', () => {
const card = makeCard();
const data = { number: 2, cards: [card] };
expect(Actions.cardsDrawn(1, 2, data)).toEqual({ type: Types.CARDS_DRAWN, gameId: 1, playerId: 2, data });
});
it('cardsRevealed', () => {
const data = { zoneName: 'hand', cards: [] } as any;
expect(Actions.cardsRevealed(1, 2, data)).toEqual({ type: Types.CARDS_REVEALED, gameId: 1, playerId: 2, data });
});
it('zoneShuffled', () => {
const data = { zoneName: 'deck', start: 0, end: 39 };
expect(Actions.zoneShuffled(1, 2, data)).toEqual({ type: Types.ZONE_SHUFFLED, gameId: 1, playerId: 2, data });
});
it('dieRolled', () => {
const data = { sides: 6, value: 4, values: [4] };
expect(Actions.dieRolled(1, 2, data)).toEqual({ type: Types.DIE_ROLLED, gameId: 1, playerId: 2, data });
});
it('activePlayerSet', () => {
expect(Actions.activePlayerSet(1, 3)).toEqual({ type: Types.ACTIVE_PLAYER_SET, gameId: 1, activePlayerId: 3 });
});
it('activePhaseSet', () => {
expect(Actions.activePhaseSet(1, 2)).toEqual({ type: Types.ACTIVE_PHASE_SET, gameId: 1, phase: 2 });
});
it('turnReversed', () => {
expect(Actions.turnReversed(1, true)).toEqual({ type: Types.TURN_REVERSED, gameId: 1, reversed: true });
});
it('zoneDumped', () => {
const data = { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false };
expect(Actions.zoneDumped(1, 2, data)).toEqual({ type: Types.ZONE_DUMPED, gameId: 1, playerId: 2, data });
});
it('zonePropertiesChanged', () => {
const data = { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false };
expect(Actions.zonePropertiesChanged(1, 2, data)).toEqual({
type: Types.ZONE_PROPERTIES_CHANGED,
gameId: 1,
playerId: 2,
data,
});
});
it('gameSay', () => {
expect(Actions.gameSay(1, 2, 'hello')).toEqual({ type: Types.GAME_SAY, gameId: 1, playerId: 2, message: 'hello' });
});
});

View file

@ -0,0 +1,234 @@
import {
AttachCardData,
ChangeZonePropertiesData,
CreateArrowData,
CreateCounterData,
CreateTokenData,
DelCounterData,
DeleteArrowData,
DestroyCardData,
DrawCardsData,
DumpZoneData,
FlipCardData,
GameStateChangedData,
MoveCardData,
PlayerProperties,
RevealCardsData,
RollDieData,
SetCardAttrData,
SetCardCounterData,
SetCounterData,
ShuffleData,
} from 'types';
import { GameEntry } from './game.interfaces';
import { Types } from './game.types';
export const Actions = {
clearStore: () => ({
type: Types.CLEAR_STORE,
}),
gameJoined: (gameId: number, gameEntry: GameEntry) => ({
type: Types.GAME_JOINED,
gameId,
gameEntry,
}),
gameLeft: (gameId: number) => ({
type: Types.GAME_LEFT,
gameId,
}),
gameClosed: (gameId: number) => ({
type: Types.GAME_CLOSED,
gameId,
}),
gameHostChanged: (gameId: number, hostId: number) => ({
type: Types.GAME_HOST_CHANGED,
gameId,
hostId,
}),
gameStateChanged: (gameId: number, data: GameStateChangedData) => ({
type: Types.GAME_STATE_CHANGED,
gameId,
data,
}),
playerJoined: (gameId: number, playerProperties: PlayerProperties) => ({
type: Types.PLAYER_JOINED,
gameId,
playerProperties,
}),
playerLeft: (gameId: number, playerId: number, reason: number) => ({
type: Types.PLAYER_LEFT,
gameId,
playerId,
reason,
}),
playerPropertiesChanged: (gameId: number, playerId: number, properties: PlayerProperties) => ({
type: Types.PLAYER_PROPERTIES_CHANGED,
gameId,
playerId,
properties,
}),
kicked: (gameId: number) => ({
type: Types.KICKED,
gameId,
}),
cardMoved: (gameId: number, playerId: number, data: MoveCardData) => ({
type: Types.CARD_MOVED,
gameId,
playerId,
data,
}),
cardFlipped: (gameId: number, playerId: number, data: FlipCardData) => ({
type: Types.CARD_FLIPPED,
gameId,
playerId,
data,
}),
cardDestroyed: (gameId: number, playerId: number, data: DestroyCardData) => ({
type: Types.CARD_DESTROYED,
gameId,
playerId,
data,
}),
cardAttached: (gameId: number, playerId: number, data: AttachCardData) => ({
type: Types.CARD_ATTACHED,
gameId,
playerId,
data,
}),
tokenCreated: (gameId: number, playerId: number, data: CreateTokenData) => ({
type: Types.TOKEN_CREATED,
gameId,
playerId,
data,
}),
cardAttrChanged: (gameId: number, playerId: number, data: SetCardAttrData) => ({
type: Types.CARD_ATTR_CHANGED,
gameId,
playerId,
data,
}),
cardCounterChanged: (gameId: number, playerId: number, data: SetCardCounterData) => ({
type: Types.CARD_COUNTER_CHANGED,
gameId,
playerId,
data,
}),
arrowCreated: (gameId: number, playerId: number, data: CreateArrowData) => ({
type: Types.ARROW_CREATED,
gameId,
playerId,
data,
}),
arrowDeleted: (gameId: number, playerId: number, data: DeleteArrowData) => ({
type: Types.ARROW_DELETED,
gameId,
playerId,
data,
}),
counterCreated: (gameId: number, playerId: number, data: CreateCounterData) => ({
type: Types.COUNTER_CREATED,
gameId,
playerId,
data,
}),
counterSet: (gameId: number, playerId: number, data: SetCounterData) => ({
type: Types.COUNTER_SET,
gameId,
playerId,
data,
}),
counterDeleted: (gameId: number, playerId: number, data: DelCounterData) => ({
type: Types.COUNTER_DELETED,
gameId,
playerId,
data,
}),
cardsDrawn: (gameId: number, playerId: number, data: DrawCardsData) => ({
type: Types.CARDS_DRAWN,
gameId,
playerId,
data,
}),
cardsRevealed: (gameId: number, playerId: number, data: RevealCardsData) => ({
type: Types.CARDS_REVEALED,
gameId,
playerId,
data,
}),
zoneShuffled: (gameId: number, playerId: number, data: ShuffleData) => ({
type: Types.ZONE_SHUFFLED,
gameId,
playerId,
data,
}),
dieRolled: (gameId: number, playerId: number, data: RollDieData) => ({
type: Types.DIE_ROLLED,
gameId,
playerId,
data,
}),
activePlayerSet: (gameId: number, activePlayerId: number) => ({
type: Types.ACTIVE_PLAYER_SET,
gameId,
activePlayerId,
}),
activePhaseSet: (gameId: number, phase: number) => ({
type: Types.ACTIVE_PHASE_SET,
gameId,
phase,
}),
turnReversed: (gameId: number, reversed: boolean) => ({
type: Types.TURN_REVERSED,
gameId,
reversed,
}),
zoneDumped: (gameId: number, playerId: number, data: DumpZoneData) => ({
type: Types.ZONE_DUMPED,
gameId,
playerId,
data,
}),
zonePropertiesChanged: (gameId: number, playerId: number, data: ChangeZonePropertiesData) => ({
type: Types.ZONE_PROPERTIES_CHANGED,
gameId,
playerId,
data,
}),
gameSay: (gameId: number, playerId: number, message: string) => ({
type: Types.GAME_SAY,
gameId,
playerId,
message,
}),
};

View file

@ -0,0 +1,198 @@
vi.mock('store/store', () => ({ store: { dispatch: vi.fn() } }));
import { store } from 'store/store';
import { Actions } from './game.actions';
import { Dispatch } from './game.dispatch';
import {
makeArrow,
makeCard,
makeCounter,
makeGameEntry,
makePlayerProperties,
} from './__mocks__/fixtures';
beforeEach(() => vi.clearAllMocks());
describe('Dispatch', () => {
it('clearStore dispatches Actions.clearStore()', () => {
Dispatch.clearStore();
expect(store.dispatch).toHaveBeenCalledWith(Actions.clearStore());
});
it('gameJoined dispatches Actions.gameJoined()', () => {
const entry = makeGameEntry();
Dispatch.gameJoined(1, entry);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameJoined(1, entry));
});
it('gameLeft dispatches Actions.gameLeft()', () => {
Dispatch.gameLeft(2);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameLeft(2));
});
it('gameClosed dispatches Actions.gameClosed()', () => {
Dispatch.gameClosed(3);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameClosed(3));
});
it('gameHostChanged dispatches Actions.gameHostChanged()', () => {
Dispatch.gameHostChanged(1, 7);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameHostChanged(1, 7));
});
it('gameStateChanged dispatches Actions.gameStateChanged()', () => {
const data = { playerList: [], gameStarted: false, activePlayerId: 0, activePhase: 0, secondsElapsed: 0 };
Dispatch.gameStateChanged(1, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameStateChanged(1, data));
});
it('playerJoined dispatches Actions.playerJoined()', () => {
const props = makePlayerProperties();
Dispatch.playerJoined(1, props);
expect(store.dispatch).toHaveBeenCalledWith(Actions.playerJoined(1, props));
});
it('playerLeft dispatches Actions.playerLeft()', () => {
Dispatch.playerLeft(1, 2, 3);
expect(store.dispatch).toHaveBeenCalledWith(Actions.playerLeft(1, 2, 3));
});
it('playerPropertiesChanged dispatches Actions.playerPropertiesChanged()', () => {
const props = makePlayerProperties();
Dispatch.playerPropertiesChanged(1, 2, props);
expect(store.dispatch).toHaveBeenCalledWith(Actions.playerPropertiesChanged(1, 2, props));
});
it('kicked dispatches Actions.kicked()', () => {
Dispatch.kicked(1);
expect(store.dispatch).toHaveBeenCalledWith(Actions.kicked(1));
});
it('cardMoved dispatches Actions.cardMoved()', () => {
const data = { cardId: 1 } as any;
Dispatch.cardMoved(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardMoved(1, 2, data));
});
it('cardFlipped dispatches Actions.cardFlipped()', () => {
const data = { cardId: 1 } as any;
Dispatch.cardFlipped(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardFlipped(1, 2, data));
});
it('cardDestroyed dispatches Actions.cardDestroyed()', () => {
const data = { cardId: 1 } as any;
Dispatch.cardDestroyed(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardDestroyed(1, 2, data));
});
it('cardAttached dispatches Actions.cardAttached()', () => {
const data = { cardId: 1 } as any;
Dispatch.cardAttached(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttached(1, 2, data));
});
it('tokenCreated dispatches Actions.tokenCreated()', () => {
const data = { cardId: 1 } as any;
Dispatch.tokenCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.tokenCreated(1, 2, data));
});
it('cardAttrChanged dispatches Actions.cardAttrChanged()', () => {
const data = { cardId: 1 } as any;
Dispatch.cardAttrChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardAttrChanged(1, 2, data));
});
it('cardCounterChanged dispatches Actions.cardCounterChanged()', () => {
const data = { cardId: 1 } as any;
Dispatch.cardCounterChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardCounterChanged(1, 2, data));
});
it('arrowCreated dispatches Actions.arrowCreated()', () => {
const data = { arrowInfo: makeArrow() };
Dispatch.arrowCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowCreated(1, 2, data));
});
it('arrowDeleted dispatches Actions.arrowDeleted()', () => {
const data = { arrowId: 3 };
Dispatch.arrowDeleted(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.arrowDeleted(1, 2, data));
});
it('counterCreated dispatches Actions.counterCreated()', () => {
const data = { counterInfo: makeCounter() };
Dispatch.counterCreated(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterCreated(1, 2, data));
});
it('counterSet dispatches Actions.counterSet()', () => {
const data = { counterId: 1, value: 10 };
Dispatch.counterSet(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterSet(1, 2, data));
});
it('counterDeleted dispatches Actions.counterDeleted()', () => {
const data = { counterId: 1 };
Dispatch.counterDeleted(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.counterDeleted(1, 2, data));
});
it('cardsDrawn dispatches Actions.cardsDrawn()', () => {
const data = { number: 2, cards: [makeCard()] };
Dispatch.cardsDrawn(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsDrawn(1, 2, data));
});
it('cardsRevealed dispatches Actions.cardsRevealed()', () => {
const data = { zoneName: 'hand', cards: [] } as any;
Dispatch.cardsRevealed(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.cardsRevealed(1, 2, data));
});
it('zoneShuffled dispatches Actions.zoneShuffled()', () => {
const data = { zoneName: 'deck', start: 0, end: 39 };
Dispatch.zoneShuffled(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneShuffled(1, 2, data));
});
it('dieRolled dispatches Actions.dieRolled()', () => {
const data = { sides: 6, value: 4, values: [4] };
Dispatch.dieRolled(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.dieRolled(1, 2, data));
});
it('activePlayerSet dispatches Actions.activePlayerSet()', () => {
Dispatch.activePlayerSet(1, 3);
expect(store.dispatch).toHaveBeenCalledWith(Actions.activePlayerSet(1, 3));
});
it('activePhaseSet dispatches Actions.activePhaseSet()', () => {
Dispatch.activePhaseSet(1, 2);
expect(store.dispatch).toHaveBeenCalledWith(Actions.activePhaseSet(1, 2));
});
it('turnReversed dispatches Actions.turnReversed()', () => {
Dispatch.turnReversed(1, true);
expect(store.dispatch).toHaveBeenCalledWith(Actions.turnReversed(1, true));
});
it('zoneDumped dispatches Actions.zoneDumped()', () => {
const data = { zoneOwnerId: 1, zoneName: 'hand', numberCards: 3, isReversed: false };
Dispatch.zoneDumped(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zoneDumped(1, 2, data));
});
it('zonePropertiesChanged dispatches Actions.zonePropertiesChanged()', () => {
const data = { zoneName: 'deck', alwaysRevealTopCard: true, alwaysLookAtTopCard: false };
Dispatch.zonePropertiesChanged(1, 2, data);
expect(store.dispatch).toHaveBeenCalledWith(Actions.zonePropertiesChanged(1, 2, data));
});
it('gameSay dispatches Actions.gameSay()', () => {
Dispatch.gameSay(1, 2, 'gg wp');
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameSay(1, 2, 'gg wp'));
});
});

View file

@ -0,0 +1,155 @@
import {
AttachCardData,
ChangeZonePropertiesData,
CreateArrowData,
CreateCounterData,
CreateTokenData,
DelCounterData,
DeleteArrowData,
DestroyCardData,
DrawCardsData,
DumpZoneData,
FlipCardData,
GameStateChangedData,
MoveCardData,
PlayerProperties,
RevealCardsData,
RollDieData,
SetCardAttrData,
SetCardCounterData,
SetCounterData,
ShuffleData,
} from 'types';
import { store } from 'store/store';
import { Actions } from './game.actions';
import { GameEntry } from './game.interfaces';
export const Dispatch = {
clearStore: () => {
store.dispatch(Actions.clearStore());
},
gameJoined: (gameId: number, gameEntry: GameEntry) => {
store.dispatch(Actions.gameJoined(gameId, gameEntry));
},
gameLeft: (gameId: number) => {
store.dispatch(Actions.gameLeft(gameId));
},
gameClosed: (gameId: number) => {
store.dispatch(Actions.gameClosed(gameId));
},
gameHostChanged: (gameId: number, hostId: number) => {
store.dispatch(Actions.gameHostChanged(gameId, hostId));
},
gameStateChanged: (gameId: number, data: GameStateChangedData) => {
store.dispatch(Actions.gameStateChanged(gameId, data));
},
playerJoined: (gameId: number, playerProperties: PlayerProperties) => {
store.dispatch(Actions.playerJoined(gameId, playerProperties));
},
playerLeft: (gameId: number, playerId: number, reason: number) => {
store.dispatch(Actions.playerLeft(gameId, playerId, reason));
},
playerPropertiesChanged: (gameId: number, playerId: number, properties: PlayerProperties) => {
store.dispatch(Actions.playerPropertiesChanged(gameId, playerId, properties));
},
kicked: (gameId: number) => {
store.dispatch(Actions.kicked(gameId));
},
cardMoved: (gameId: number, playerId: number, data: MoveCardData) => {
store.dispatch(Actions.cardMoved(gameId, playerId, data));
},
cardFlipped: (gameId: number, playerId: number, data: FlipCardData) => {
store.dispatch(Actions.cardFlipped(gameId, playerId, data));
},
cardDestroyed: (gameId: number, playerId: number, data: DestroyCardData) => {
store.dispatch(Actions.cardDestroyed(gameId, playerId, data));
},
cardAttached: (gameId: number, playerId: number, data: AttachCardData) => {
store.dispatch(Actions.cardAttached(gameId, playerId, data));
},
tokenCreated: (gameId: number, playerId: number, data: CreateTokenData) => {
store.dispatch(Actions.tokenCreated(gameId, playerId, data));
},
cardAttrChanged: (gameId: number, playerId: number, data: SetCardAttrData) => {
store.dispatch(Actions.cardAttrChanged(gameId, playerId, data));
},
cardCounterChanged: (gameId: number, playerId: number, data: SetCardCounterData) => {
store.dispatch(Actions.cardCounterChanged(gameId, playerId, data));
},
arrowCreated: (gameId: number, playerId: number, data: CreateArrowData) => {
store.dispatch(Actions.arrowCreated(gameId, playerId, data));
},
arrowDeleted: (gameId: number, playerId: number, data: DeleteArrowData) => {
store.dispatch(Actions.arrowDeleted(gameId, playerId, data));
},
counterCreated: (gameId: number, playerId: number, data: CreateCounterData) => {
store.dispatch(Actions.counterCreated(gameId, playerId, data));
},
counterSet: (gameId: number, playerId: number, data: SetCounterData) => {
store.dispatch(Actions.counterSet(gameId, playerId, data));
},
counterDeleted: (gameId: number, playerId: number, data: DelCounterData) => {
store.dispatch(Actions.counterDeleted(gameId, playerId, data));
},
cardsDrawn: (gameId: number, playerId: number, data: DrawCardsData) => {
store.dispatch(Actions.cardsDrawn(gameId, playerId, data));
},
cardsRevealed: (gameId: number, playerId: number, data: RevealCardsData) => {
store.dispatch(Actions.cardsRevealed(gameId, playerId, data));
},
zoneShuffled: (gameId: number, playerId: number, data: ShuffleData) => {
store.dispatch(Actions.zoneShuffled(gameId, playerId, data));
},
dieRolled: (gameId: number, playerId: number, data: RollDieData) => {
store.dispatch(Actions.dieRolled(gameId, playerId, data));
},
activePlayerSet: (gameId: number, activePlayerId: number) => {
store.dispatch(Actions.activePlayerSet(gameId, activePlayerId));
},
activePhaseSet: (gameId: number, phase: number) => {
store.dispatch(Actions.activePhaseSet(gameId, phase));
},
turnReversed: (gameId: number, reversed: boolean) => {
store.dispatch(Actions.turnReversed(gameId, reversed));
},
zoneDumped: (gameId: number, playerId: number, data: DumpZoneData) => {
store.dispatch(Actions.zoneDumped(gameId, playerId, data));
},
zonePropertiesChanged: (gameId: number, playerId: number, data: ChangeZonePropertiesData) => {
store.dispatch(Actions.zonePropertiesChanged(gameId, playerId, data));
},
gameSay: (gameId: number, playerId: number, message: string) => {
store.dispatch(Actions.gameSay(gameId, playerId, message));
},
};

View file

@ -0,0 +1,60 @@
import { ArrowInfo, CardInfo, CounterInfo, PlayerProperties } from 'types';
export interface GamesState {
games: { [gameId: number]: GameEntry };
}
/**
* Full runtime state for a single active game (played or spectated).
* Keyed by gameId in GamesState so multiple concurrent games are supported.
*/
export interface GameEntry {
gameId: number;
roomId: number;
description: string;
hostId: number;
/** The playerId assigned to the local user in this game. */
localPlayerId: number;
spectator: boolean;
judge: boolean;
resuming: boolean;
started: boolean;
activePlayerId: number;
activePhase: number;
secondsElapsed: number;
reversed: boolean;
players: { [playerId: number]: PlayerEntry };
messages: GameMessage[];
}
/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */
export interface PlayerEntry {
properties: PlayerProperties;
deckList: string;
/** Zones keyed by zone name (e.g. "hand", "deck", "table"). */
zones: { [zoneName: string]: ZoneEntry };
/** Player-level counters (e.g. life) keyed by counter id. */
counters: { [counterId: number]: CounterInfo };
/** Arrows keyed by arrow id. */
arrows: { [arrowId: number]: ArrowInfo };
}
/** Normalized from ServerInfo_Zone — card list is an ordered array matching proto. */
export interface ZoneEntry {
name: string;
/** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */
type: number;
withCoords: boolean;
/** Authoritative card count (used for hidden zones where cardList may be empty). */
cardCount: number;
/** Ordered card list; may be empty for hidden zones with no dump active. */
cards: CardInfo[];
alwaysRevealTopCard: boolean;
alwaysLookAtTopCard: boolean;
}
export interface GameMessage {
playerId: number;
message: string;
timeReceived: number;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,729 @@
import {
ArrowInfo,
CardAttribute,
CardCounterInfo,
CardInfo,
CounterInfo,
PlayerInfo,
PlayerProperties,
} from 'types';
import { GameEntry, GameMessage, GamesState, PlayerEntry, ZoneEntry } from './game.interfaces';
import { Types } from './game.types';
// ── Helpers ──────────────────────────────────────────────────────────────────
function updateGame(state: GamesState, gameId: number, updates: Partial<GameEntry>): GamesState {
const game = state.games[gameId];
if (!game) {
return state;
}
return {
...state,
games: { ...state.games, [gameId]: { ...game, ...updates } },
};
}
function updatePlayer(
state: GamesState,
gameId: number,
playerId: number,
updates: Partial<PlayerEntry>
): GamesState {
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
return updateGame(state, gameId, {
players: { ...game.players, [playerId]: { ...player, ...updates } },
});
}
function updateZone(
state: GamesState,
gameId: number,
playerId: number,
zoneName: string,
updates: Partial<ZoneEntry>
): GamesState {
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
return updatePlayer(state, gameId, playerId, {
zones: { ...player.zones, [zoneName]: { ...zone, ...updates } },
});
}
function removeGame(state: GamesState, gameId: number): GamesState {
const games = { ...state.games };
delete games[gameId];
return { ...state, games };
}
/** Converts the proto PlayerInfo[] array into the keyed PlayerEntry map used in the store. */
function normalizePlayers(playerList: PlayerInfo[]): { [playerId: number]: PlayerEntry } {
const players: { [playerId: number]: PlayerEntry } = {};
for (const player of playerList) {
const playerId = player.properties.playerId;
const zones: { [zoneName: string]: ZoneEntry } = {};
for (const zone of player.zoneList) {
zones[zone.name] = {
name: zone.name,
type: zone.type,
withCoords: zone.withCoords,
cardCount: zone.cardCount,
cards: [...zone.cardList],
alwaysRevealTopCard: zone.alwaysRevealTopCard,
alwaysLookAtTopCard: zone.alwaysLookAtTopCard,
};
}
const counters: { [counterId: number]: CounterInfo } = {};
for (const counter of player.counterList) {
counters[counter.id] = counter;
}
const arrows: { [arrowId: number]: ArrowInfo } = {};
for (const arrow of player.arrowList) {
arrows[arrow.id] = arrow;
}
players[playerId] = {
properties: player.properties,
deckList: player.deckList,
zones,
counters,
arrows,
};
}
return players;
}
function buildEmptyCard(
id: number,
name: string,
x: number,
y: number,
faceDown: boolean,
providerId: string
): CardInfo {
return {
id,
name,
x,
y,
faceDown,
tapped: false,
attacking: false,
color: '',
pt: '',
annotation: '',
destroyOnZoneChange: false,
doesntUntap: false,
counterList: [],
attachPlayerId: -1,
attachZone: '',
attachCardId: -1,
providerId,
};
}
// ── Initial state ─────────────────────────────────────────────────────────────
const initialState: GamesState = {
games: {},
};
// ── Reducer ───────────────────────────────────────────────────────────────────
export const gamesReducer = (state: GamesState = initialState, action: any): GamesState => {
switch (action.type) {
case Types.CLEAR_STORE: {
return initialState;
}
case Types.GAME_JOINED: {
return {
...state,
games: { ...state.games, [action.gameId]: action.gameEntry },
};
}
case Types.GAME_LEFT:
case Types.GAME_CLOSED:
case Types.KICKED: {
return removeGame(state, action.gameId);
}
case Types.GAME_HOST_CHANGED: {
return updateGame(state, action.gameId, { hostId: action.hostId });
}
case Types.GAME_STATE_CHANGED: {
const { gameId, data } = action;
const game = state.games[gameId];
if (!game) {
return state;
}
const updates: Partial<GameEntry> = {};
if (data.playerList?.length > 0) {
updates.players = normalizePlayers(data.playerList);
}
if (data.gameStarted !== undefined && data.gameStarted !== null) {
updates.started = data.gameStarted;
}
if (data.activePlayerId !== undefined && data.activePlayerId !== null) {
updates.activePlayerId = data.activePlayerId;
}
if (data.activePhase !== undefined && data.activePhase !== null) {
updates.activePhase = data.activePhase;
}
if (data.secondsElapsed !== undefined) {
updates.secondsElapsed = data.secondsElapsed;
}
return updateGame(state, gameId, updates);
}
case Types.PLAYER_JOINED: {
const { gameId, playerProperties } = action;
const game = state.games[gameId];
if (!game) {
return state;
}
const newPlayer: PlayerEntry = {
properties: playerProperties as PlayerProperties,
deckList: '',
zones: {},
counters: {},
arrows: {},
};
return updateGame(state, gameId, {
players: { ...game.players, [playerProperties.playerId]: newPlayer },
});
}
case Types.PLAYER_LEFT: {
const { gameId, playerId } = action;
const game = state.games[gameId];
if (!game) {
return state;
}
const players = { ...game.players };
delete players[playerId];
return updateGame(state, gameId, { players });
}
case Types.PLAYER_PROPERTIES_CHANGED: {
return updatePlayer(state, action.gameId, action.playerId, {
properties: action.properties,
});
}
// ── Card manipulation ────────────────────────────────────────────────────
case Types.CARD_MOVED: {
const { gameId, playerId, data } = action;
const {
cardId,
cardName,
startPlayerId,
startZone,
position,
targetPlayerId,
targetZone,
x,
y,
newCardId,
faceDown,
newCardProviderId,
} = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const effectiveStartPlayerId = startPlayerId >= 0 ? startPlayerId : playerId;
const sourcePlayer = game.players[effectiveStartPlayerId];
const sourceZoneEntry = sourcePlayer?.zones[startZone];
if (!sourcePlayer || !sourceZoneEntry) {
return state;
}
// Locate card in source zone (by id for visible zones, by position for hidden)
let removedCard: CardInfo | undefined;
let newSourceCards: CardInfo[];
if (cardId >= 0) {
removedCard = sourceZoneEntry.cards.find(c => c.id === cardId);
newSourceCards = sourceZoneEntry.cards.filter(c => c.id !== cardId);
} else if (position >= 0 && position < sourceZoneEntry.cards.length) {
removedCard = sourceZoneEntry.cards[position];
newSourceCards = sourceZoneEntry.cards.filter((_, i) => i !== position);
} else {
// Hidden zone with unknown position — just decrement count
newSourceCards = sourceZoneEntry.cards;
}
const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1);
const movedCard: CardInfo = removedCard
? {
...removedCard,
id: effectiveNewId,
name: cardName || removedCard.name,
x,
y,
faceDown,
providerId: newCardProviderId || removedCard.providerId,
}
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
let newState = updateZone(state, gameId, effectiveStartPlayerId, startZone, {
cards: newSourceCards,
cardCount: Math.max(0, sourceZoneEntry.cardCount - 1),
});
const updatedGame = newState.games[gameId];
const targetPlayer = updatedGame?.players[targetPlayerId];
const targetZoneEntry = targetPlayer?.zones[targetZone];
if (!targetPlayer || !targetZoneEntry) {
return newState;
}
newState = updateZone(newState, gameId, targetPlayerId, targetZone, {
cards: [...targetZoneEntry.cards, movedCard],
cardCount: targetZoneEntry.cardCount + 1,
});
return newState;
}
case Types.CARD_FLIPPED: {
const { gameId, playerId, data } = action;
const { zoneName, cardId, cardName, faceDown, cardProviderId } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = {
...updatedCards[cardIdx],
faceDown,
name: cardName || updatedCards[cardIdx].name,
providerId: cardProviderId || updatedCards[cardIdx].providerId,
};
return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards });
}
case Types.CARD_DESTROYED: {
const { gameId, playerId, data } = action;
const { zoneName, cardId } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
return updateZone(state, gameId, playerId, zoneName, {
cards: zone.cards.filter(c => c.id !== cardId),
cardCount: Math.max(0, zone.cardCount - 1),
});
}
case Types.CARD_ATTACHED: {
const { gameId, playerId, data } = action;
const { startZone, cardId, targetPlayerId, targetZone, targetCardId } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[startZone];
if (!zone) {
return state;
}
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = {
...updatedCards[cardIdx],
attachPlayerId: targetPlayerId,
attachZone: targetZone,
attachCardId: targetCardId,
};
return updateZone(state, gameId, playerId, startZone, { cards: updatedCards });
}
case Types.TOKEN_CREATED: {
const { gameId, playerId, data } = action;
const {
zoneName,
cardId,
cardName,
color,
pt,
annotation,
destroyOnZoneChange,
x,
y,
cardProviderId,
faceDown,
} = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
const newCard: CardInfo = {
id: cardId,
name: cardName,
x,
y,
faceDown,
tapped: false,
attacking: false,
color,
pt,
annotation,
destroyOnZoneChange,
doesntUntap: false,
counterList: [],
attachPlayerId: -1,
attachZone: '',
attachCardId: -1,
providerId: cardProviderId,
};
return updateZone(state, gameId, playerId, zoneName, {
cards: [...zone.cards, newCard],
cardCount: zone.cardCount + 1,
});
}
case Types.CARD_ATTR_CHANGED: {
const { gameId, playerId, data } = action;
const { zoneName, cardId, attribute, attrValue } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const attrPatch: Partial<CardInfo> = {};
switch (attribute as CardAttribute) {
case CardAttribute.AttrTapped: attrPatch.tapped = attrValue === '1'; break;
case CardAttribute.AttrAttacking: attrPatch.attacking = attrValue === '1'; break;
case CardAttribute.AttrFaceDown: attrPatch.faceDown = attrValue === '1'; break;
case CardAttribute.AttrColor: attrPatch.color = attrValue; break;
case CardAttribute.AttrPT: attrPatch.pt = attrValue; break;
case CardAttribute.AttrAnnotation: attrPatch.annotation = attrValue; break;
case CardAttribute.AttrDoesntUntap: attrPatch.doesntUntap = attrValue === '1'; break;
}
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = { ...updatedCards[cardIdx], ...attrPatch };
return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards });
}
case Types.CARD_COUNTER_CHANGED: {
const { gameId, playerId, data } = action;
const { zoneName, cardId, counterId, counterValue } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
const cardIdx = zone.cards.findIndex(c => c.id === cardId);
if (cardIdx < 0) {
return state;
}
const card = zone.cards[cardIdx];
let newCounterList: CardCounterInfo[];
if (counterValue <= 0) {
newCounterList = card.counterList.filter(c => c.id !== counterId);
} else {
const existing = card.counterList.findIndex(c => c.id === counterId);
newCounterList =
existing >= 0
? card.counterList.map(c => (c.id === counterId ? { ...c, value: counterValue } : c))
: [...card.counterList, { id: counterId, value: counterValue }];
}
const updatedCards = [...zone.cards];
updatedCards[cardIdx] = { ...card, counterList: newCounterList };
return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards });
}
// ── Arrows ───────────────────────────────────────────────────────────────
case Types.ARROW_CREATED: {
const { gameId, playerId, data } = action;
const { arrowInfo } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
return updatePlayer(state, gameId, playerId, {
arrows: { ...player.arrows, [arrowInfo.id]: arrowInfo },
});
}
case Types.ARROW_DELETED: {
const { gameId, playerId, data } = action;
const { arrowId } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const arrows = { ...player.arrows };
delete arrows[arrowId];
return updatePlayer(state, gameId, playerId, { arrows });
}
// ── Player counters ───────────────────────────────────────────────────────
case Types.COUNTER_CREATED: {
const { gameId, playerId, data } = action;
const { counterInfo } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
return updatePlayer(state, gameId, playerId, {
counters: { ...player.counters, [counterInfo.id]: counterInfo },
});
}
case Types.COUNTER_SET: {
const { gameId, playerId, data } = action;
const { counterId, value } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const counter = player.counters[counterId];
if (!counter) {
return state;
}
return updatePlayer(state, gameId, playerId, {
counters: { ...player.counters, [counterId]: { ...counter, count: value } },
});
}
case Types.COUNTER_DELETED: {
const { gameId, playerId, data } = action;
const { counterId } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const counters = { ...player.counters };
delete counters[counterId];
return updatePlayer(state, gameId, playerId, { counters });
}
// ── Zone operations ───────────────────────────────────────────────────────
case Types.CARDS_DRAWN: {
const { gameId, playerId, data } = action;
const { number: drawCount, cards } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const deckZone = player.zones['deck'];
const handZone = player.zones['hand'];
if (!handZone) {
return state;
}
// Decrement deck count for the drawing player
let newState = deckZone
? updateZone(state, gameId, playerId, 'deck', {
cardCount: Math.max(0, deckZone.cardCount - drawCount),
})
: state;
// Append revealed cards to hand (cards array is empty for non-drawing players;
// use drawCount for count math so all observers track the correct hand/deck size)
const updatedHand = newState.games[gameId]!.players[playerId]!.zones['hand']!;
return updateZone(newState, gameId, playerId, 'hand', {
cards: [...updatedHand.cards, ...cards],
cardCount: updatedHand.cardCount + drawCount,
});
}
case Types.CARDS_REVEALED: {
const { gameId, playerId, data } = action;
const { zoneName, cards } = data;
const game = state.games[gameId];
if (!game) {
return state;
}
const player = game.players[playerId];
if (!player) {
return state;
}
const zone = player.zones[zoneName];
if (!zone) {
return state;
}
// Merge revealed card data into existing zone cards (update existing, append new)
const merged = [...zone.cards];
for (const revealedCard of cards) {
const idx = merged.findIndex(c => c.id === revealedCard.id);
if (idx >= 0) {
merged[idx] = { ...merged[idx], ...revealedCard };
} else {
merged.push(revealedCard);
}
}
return updateZone(state, gameId, playerId, zoneName, { cards: merged });
}
case Types.ZONE_PROPERTIES_CHANGED: {
const { gameId, playerId, data } = action;
const { zoneName, alwaysRevealTopCard, alwaysLookAtTopCard } = data;
const patch: Partial<ZoneEntry> = {};
if (alwaysRevealTopCard !== undefined && alwaysRevealTopCard !== null) {
patch.alwaysRevealTopCard = alwaysRevealTopCard;
}
if (alwaysLookAtTopCard !== undefined && alwaysLookAtTopCard !== null) {
patch.alwaysLookAtTopCard = alwaysLookAtTopCard;
}
return updateZone(state, gameId, playerId, zoneName, patch);
}
// ── Turn / phase ──────────────────────────────────────────────────────────
case Types.ACTIVE_PLAYER_SET: {
return updateGame(state, action.gameId, { activePlayerId: action.activePlayerId });
}
case Types.ACTIVE_PHASE_SET: {
return updateGame(state, action.gameId, { activePhase: action.phase });
}
case Types.TURN_REVERSED: {
return updateGame(state, action.gameId, { reversed: action.reversed });
}
// ── Chat ──────────────────────────────────────────────────────────────────
case Types.GAME_SAY: {
const { gameId, playerId, message } = action;
const game = state.games[gameId];
if (!game) {
return state;
}
const newMessage: GameMessage = { playerId, message, timeReceived: Date.now() };
return updateGame(state, gameId, {
messages: [...game.messages, newMessage],
});
}
// ── Log-only events (state unchanged, future game log will use these) ─────
case Types.ZONE_SHUFFLED:
case Types.ZONE_DUMPED:
case Types.DIE_ROLLED: {
return state;
}
default:
return state;
}
};

View file

@ -0,0 +1,158 @@
import { Selectors } from './game.selectors';
import {
makeGameEntry, makePlayerEntry, makePlayerProperties, makeState,
makeZoneEntry, makeCard, makeCounter, makeArrow,
} from './__mocks__/fixtures';
import { GamesState } from './game.interfaces';
function rootState(games: GamesState) {
return { games };
}
describe('Selectors', () => {
it('getGames → returns the games map', () => {
const state = makeState();
expect(Selectors.getGames(rootState(state))).toBe(state.games);
});
it('getGame → returns the game entry for a given gameId', () => {
const state = makeState();
expect(Selectors.getGame(rootState(state), 1)).toBe(state.games[1]);
});
it('getGame → returns undefined for unknown gameId', () => {
const state = makeState();
expect(Selectors.getGame(rootState(state), 999)).toBeUndefined();
});
it('getPlayers → returns players map for a game', () => {
const state = makeState();
expect(Selectors.getPlayers(rootState(state), 1)).toBe(state.games[1].players);
});
it('getPlayers → returns undefined for unknown gameId', () => {
const state = makeState();
expect(Selectors.getPlayers(rootState(state), 999)).toBeUndefined();
});
it('getPlayer → returns a specific player', () => {
const state = makeState();
expect(Selectors.getPlayer(rootState(state), 1, 1)).toBe(state.games[1].players[1]);
});
it('getLocalPlayerId → returns localPlayerId from game', () => {
const state = makeState({ games: { 1: makeGameEntry({ localPlayerId: 42 }) } });
expect(Selectors.getLocalPlayerId(rootState(state), 1)).toBe(42);
});
it('getLocalPlayer → returns the player matching localPlayerId', () => {
const state = makeState({ games: { 1: makeGameEntry({ localPlayerId: 1 }) } });
const result = Selectors.getLocalPlayer(rootState(state), 1);
expect(result).toBe(state.games[1].players[1]);
});
it('getLocalPlayer → returns undefined when game is not found', () => {
const state = makeState();
expect(Selectors.getLocalPlayer(rootState(state), 999)).toBeUndefined();
});
it('getZones → returns zones map for a player', () => {
const state = makeState();
expect(Selectors.getZones(rootState(state), 1, 1)).toBe(state.games[1].players[1].zones);
});
it('getZone → returns a specific zone', () => {
const state = makeState();
expect(Selectors.getZone(rootState(state), 1, 1, 'hand')).toBe(state.games[1].players[1].zones['hand']);
});
it('getCards → returns cards array for a zone', () => {
const card = makeCard();
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: { hand: makeZoneEntry({ name: 'hand', cards: [card] }) },
}),
},
}),
},
});
expect(Selectors.getCards(rootState(state), 1, 1, 'hand')).toEqual([card]);
});
it('getCards → returns [] when zone not found', () => {
const state = makeState();
expect(Selectors.getCards(rootState(state), 1, 1, 'nonexistent')).toEqual([]);
});
it('getCounters → returns counters map for a player', () => {
const counter = makeCounter({ id: 2 });
const state = makeState({
games: { 1: makeGameEntry({ players: { 1: makePlayerEntry({ counters: { 2: counter } }) } }) },
});
expect(Selectors.getCounters(rootState(state), 1, 1)).toEqual({ 2: counter });
});
it('getArrows → returns arrows map for a player', () => {
const arrow = makeArrow({ id: 3 });
const state = makeState({
games: { 1: makeGameEntry({ players: { 1: makePlayerEntry({ arrows: { 3: arrow } }) } }) },
});
expect(Selectors.getArrows(rootState(state), 1, 1)).toEqual({ 3: arrow });
});
it('getActivePlayerId → returns activePlayerId from game', () => {
const state = makeState({ games: { 1: makeGameEntry({ activePlayerId: 7 }) } });
expect(Selectors.getActivePlayerId(rootState(state), 1)).toBe(7);
});
it('getActivePhase → returns activePhase from game', () => {
const state = makeState({ games: { 1: makeGameEntry({ activePhase: 3 }) } });
expect(Selectors.getActivePhase(rootState(state), 1)).toBe(3);
});
it('isStarted → returns true when game is started', () => {
const state = makeState({ games: { 1: makeGameEntry({ started: true }) } });
expect(Selectors.isStarted(rootState(state), 1)).toBe(true);
});
it('isStarted → returns false when game not found', () => {
const state = makeState();
expect(Selectors.isStarted(rootState(state), 999)).toBe(false);
});
it('isSpectator → returns spectator flag from game', () => {
const state = makeState({ games: { 1: makeGameEntry({ spectator: true }) } });
expect(Selectors.isSpectator(rootState(state), 1)).toBe(true);
});
it('isReversed → returns reversed flag from game', () => {
const state = makeState({ games: { 1: makeGameEntry({ reversed: true }) } });
expect(Selectors.isReversed(rootState(state), 1)).toBe(true);
});
it('getMessages → returns messages array from game', () => {
const messages = [{ playerId: 1, message: 'hi', timeReceived: 100 }];
const state = makeState({ games: { 1: makeGameEntry({ messages }) } });
expect(Selectors.getMessages(rootState(state), 1)).toBe(messages);
});
it('getMessages → returns [] when game not found', () => {
const state = makeState();
expect(Selectors.getMessages(rootState(state), 999)).toEqual([]);
});
it('getActiveGameIds → returns numeric array of gameIds', () => {
const state = makeState({
games: {
1: makeGameEntry({ gameId: 1 }),
2: makeGameEntry({ gameId: 2 }),
},
});
const ids = Selectors.getActiveGameIds(rootState(state));
expect(ids).toEqual(expect.arrayContaining([1, 2]));
expect(ids).toHaveLength(2);
});
});

View file

@ -0,0 +1,72 @@
import { GamesState, GameEntry, PlayerEntry, ZoneEntry } from './game.interfaces';
interface State {
games: GamesState;
}
export const Selectors = {
getGames: ({ games }: State): { [gameId: number]: GameEntry } => games.games,
getGame: ({ games }: State, gameId: number): GameEntry | undefined => games.games[gameId],
getPlayers: ({ games }: State, gameId: number): { [playerId: number]: PlayerEntry } | undefined =>
games.games[gameId]?.players,
getPlayer: ({ games }: State, gameId: number, playerId: number): PlayerEntry | undefined =>
games.games[gameId]?.players[playerId],
getLocalPlayerId: ({ games }: State, gameId: number): number | undefined =>
games.games[gameId]?.localPlayerId,
getLocalPlayer: (state: State, gameId: number): PlayerEntry | undefined => {
const game = state.games.games[gameId];
if (!game) {
return undefined;
}
return game.players[game.localPlayerId];
},
getZones: (
{ games }: State,
gameId: number,
playerId: number
): { [zoneName: string]: ZoneEntry } | undefined =>
games.games[gameId]?.players[playerId]?.zones,
getZone: (
{ games }: State,
gameId: number,
playerId: number,
zoneName: string
): ZoneEntry | undefined => games.games[gameId]?.players[playerId]?.zones[zoneName],
getCards: ({ games }: State, gameId: number, playerId: number, zoneName: string) =>
games.games[gameId]?.players[playerId]?.zones[zoneName]?.cards ?? [],
getCounters: ({ games }: State, gameId: number, playerId: number) =>
games.games[gameId]?.players[playerId]?.counters ?? {},
getArrows: ({ games }: State, gameId: number, playerId: number) =>
games.games[gameId]?.players[playerId]?.arrows ?? {},
getActivePlayerId: ({ games }: State, gameId: number): number | undefined =>
games.games[gameId]?.activePlayerId,
getActivePhase: ({ games }: State, gameId: number): number | undefined =>
games.games[gameId]?.activePhase,
isStarted: ({ games }: State, gameId: number): boolean =>
games.games[gameId]?.started ?? false,
isSpectator: ({ games }: State, gameId: number): boolean =>
games.games[gameId]?.spectator ?? false,
isReversed: ({ games }: State, gameId: number): boolean =>
games.games[gameId]?.reversed ?? false,
getMessages: ({ games }: State, gameId: number) =>
games.games[gameId]?.messages ?? [],
getActiveGameIds: ({ games }: State): number[] =>
Object.keys(games.games).map(Number),
};

View file

@ -0,0 +1,34 @@
export const Types = {
CLEAR_STORE: '[Games] Clear Store',
GAME_JOINED: '[Games] Game Joined',
GAME_LEFT: '[Games] Game Left',
GAME_CLOSED: '[Games] Game Closed',
GAME_HOST_CHANGED: '[Games] Game Host Changed',
GAME_STATE_CHANGED: '[Games] Game State Changed',
PLAYER_JOINED: '[Games] Player Joined',
PLAYER_LEFT: '[Games] Player Left',
PLAYER_PROPERTIES_CHANGED: '[Games] Player Properties Changed',
KICKED: '[Games] Kicked',
CARD_MOVED: '[Games] Card Moved',
CARD_FLIPPED: '[Games] Card Flipped',
CARD_DESTROYED: '[Games] Card Destroyed',
CARD_ATTACHED: '[Games] Card Attached',
TOKEN_CREATED: '[Games] Token Created',
CARD_ATTR_CHANGED: '[Games] Card Attribute Changed',
CARD_COUNTER_CHANGED: '[Games] Card Counter Changed',
ARROW_CREATED: '[Games] Arrow Created',
ARROW_DELETED: '[Games] Arrow Deleted',
COUNTER_CREATED: '[Games] Counter Created',
COUNTER_SET: '[Games] Counter Set',
COUNTER_DELETED: '[Games] Counter Deleted',
CARDS_DRAWN: '[Games] Cards Drawn',
CARDS_REVEALED: '[Games] Cards Revealed',
ZONE_SHUFFLED: '[Games] Zone Shuffled',
DIE_ROLLED: '[Games] Die Rolled',
ACTIVE_PLAYER_SET: '[Games] Active Player Set',
ACTIVE_PHASE_SET: '[Games] Active Phase Set',
TURN_REVERSED: '[Games] Turn Reversed',
ZONE_DUMPED: '[Games] Zone Dumped',
ZONE_PROPERTIES_CHANGED: '[Games] Zone Properties Changed',
GAME_SAY: '[Games] Game Say',
};

View file

@ -0,0 +1,6 @@
export { Types } from './game.types';
export { gamesReducer } from './game.reducer';
export { Actions } from './game.actions';
export { Dispatch } from './game.dispatch';
export { Selectors } from './game.selectors';
export * from './game.interfaces';

View file

@ -3,8 +3,15 @@ export { store } from './store';
// Common
export { SortUtil } from './common';
// Server
// Games
export {
Types as GameTypes,
Selectors as GameSelectors,
Dispatch as GameDispatch } from './game';
export * from 'store/game/game.interfaces';
// Server
export {
Types as ServerTypes,
Selectors as ServerSelectors,

View file

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

View file

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

View file

@ -0,0 +1,90 @@
vi.mock('store/store', () => ({ store: { dispatch: vi.fn() } }));
vi.mock('redux-form', () => ({
reset: vi.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(() => vi.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 vi.Mock)('sayMessage'));
expect(store.dispatch).toHaveBeenNthCalledWith(2, Actions.addMessage(1, message));
});
it('updateGames dispatches Actions.updateGames()', () => {
const games = [makeGame()];
Dispatch.updateGames(1, games);
expect(store.dispatch).toHaveBeenCalledWith(Actions.updateGames(1, games));
});
it('userJoined dispatches Actions.userJoined()', () => {
const user = makeUser();
Dispatch.userJoined(1, user);
expect(store.dispatch).toHaveBeenCalledWith(Actions.userJoined(1, user));
});
it('userLeft dispatches Actions.userLeft()', () => {
Dispatch.userLeft(1, 'Alice');
expect(store.dispatch).toHaveBeenCalledWith(Actions.userLeft(1, 'Alice'));
});
it('sortGames dispatches Actions.sortGames()', () => {
Dispatch.sortGames(1, GameSortField.START_TIME, SortDirection.ASC);
expect(store.dispatch).toHaveBeenCalledWith(
Actions.sortGames(1, GameSortField.START_TIME, SortDirection.ASC)
);
});
it('removeMessages dispatches Actions.removeMessages()', () => {
Dispatch.removeMessages(1, 'Alice', 5);
expect(store.dispatch).toHaveBeenCalledWith(Actions.removeMessages(1, 'Alice', 5));
});
it('gameCreated dispatches Actions.gameCreated()', () => {
Dispatch.gameCreated(2);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gameCreated(2));
});
it('joinedGame dispatches Actions.joinedGame()', () => {
Dispatch.joinedGame(1, 5);
expect(store.dispatch).toHaveBeenCalledWith(Actions.joinedGame(1, 5));
});
});

View file

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

View file

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

View file

@ -1,11 +1,13 @@
import { combineReducers } from 'redux';
import { gamesReducer } from './game';
import { roomsReducer } from './rooms';
import { serverReducer } from './server';
import { reducer as formReducer } from 'redux-form'
import { actionReducer } from './actions'
export default combineReducers({
games: gamesReducer,
rooms: roomsReducer,
server: serverReducer,

View file

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

View file

@ -0,0 +1,353 @@
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_SUCCESS });
});
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('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 });
});
it('gamesOfUser', () => {
const games = [{ gameId: 1 }] as any;
expect(Actions.gamesOfUser('alice', games)).toEqual({ type: Types.GAMES_OF_USER, userName: 'alice', games });
});
});

View file

@ -1,4 +1,4 @@
import { DeckList, DeckStorageTreeItem, ReplayMatch, WebSocketConnectOptions } from 'types';
import { DeckList, DeckStorageTreeItem, Game, ReplayMatch, WebSocketConnectOptions } from 'types';
import { Types } from './server.types';
export const Actions = {
@ -91,7 +91,7 @@ export const Actions = {
type: Types.REGISTRATION_REQUIRES_EMAIL,
}),
registrationSuccess: () => ({
type: Types.REGISTRATION_SUCCES,
type: Types.REGISTRATION_SUCCESS,
}),
registrationFailed: (error) => ({
type: Types.REGISTRATION_FAILED,
@ -157,11 +157,6 @@ export const Actions = {
type: Types.ACCOUNT_IMAGE_CHANGED,
user,
}),
directMessageSent: (userName, message) => ({
type: Types.DIRECT_MESSAGE_SENT,
userName,
message,
}),
getUserInfo: (userInfo) => ({
type: Types.GET_USER_INFO,
userInfo,
@ -239,4 +234,5 @@ export const Actions = {
deckDelDir: (path: string) => ({ type: Types.DECK_DEL_DIR, path }),
deckUpload: (path: string, treeItem: DeckStorageTreeItem) => ({ type: Types.DECK_UPLOAD, path, treeItem }),
deckDelete: (deckId: number) => ({ type: Types.DECK_DELETE, deckId }),
gamesOfUser: (userName: string, games: Game[]) => ({ type: Types.GAMES_OF_USER, userName, games }),
}

View file

@ -0,0 +1,389 @@
vi.mock('store/store', () => ({ store: { dispatch: vi.fn() } }));
vi.mock('redux-form', () => ({
reset: vi.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(() => vi.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 vi.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 vi.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('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));
});
it('gamesOfUser dispatches correctly', () => {
const games = [{ gameId: 1 }] as any;
Dispatch.gamesOfUser('alice', games);
expect(store.dispatch).toHaveBeenCalledWith(Actions.gamesOfUser('alice', games));
});
});

View file

@ -1,7 +1,7 @@
import { reset } from 'redux-form';
import { Actions } from './server.actions';
import { store } from 'store';
import { DeckList, DeckStorageTreeItem, ReplayMatch, WebSocketConnectOptions } from 'types';
import { DeckList, DeckStorageTreeItem, Game, ReplayMatch, WebSocketConnectOptions } from 'types';
export const Dispatch = {
initialized: () => {
@ -141,9 +141,6 @@ export const Dispatch = {
accountImageChanged: (user) => {
store.dispatch(Actions.accountImageChanged(user));
},
directMessageSent: (userName, message) => {
store.dispatch(Actions.directMessageSent(userName, message));
},
getUserInfo: (userInfo) => {
store.dispatch(Actions.getUserInfo(userInfo));
},
@ -216,4 +213,7 @@ export const Dispatch = {
deckDelete: (deckId: number) => {
store.dispatch(Actions.deckDelete(deckId));
},
gamesOfUser: (userName: string, games: Game[]) => {
store.dispatch(Actions.gamesOfUser(userName, games));
},
}

View file

@ -1,5 +1,5 @@
import {
WarnHistoryItem, BanHistoryItem, DeckList, LogItem, ReplayMatch, SortBy, User, UserSortField, WebSocketConnectOptions, WarnListItem
WarnHistoryItem, BanHistoryItem, DeckList, Game, LogItem, ReplayMatch, SortBy, User, UserSortField, WarnListItem
} from 'types';
import { NotifyUserData, ServerShutdownData, UserMessageData } from 'websocket/events/session/interfaces';
@ -51,7 +51,6 @@ export interface ServerState {
user: User;
users: User[];
sortUsersBy: ServerStateSortUsersBy;
connectOptions: WebSocketConnectOptions;
messages: {
[userName: string]: UserMessageData[];
}
@ -72,6 +71,7 @@ export interface ServerState {
adminNotes: { [userName: string]: string };
replays: ReplayMatch[];
backendDecks: DeckList | null;
gamesOfUser: { [userName: string]: Game[] };
}
export interface ServerStateStatus {

View file

@ -0,0 +1,560 @@
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 → returns state unchanged', () => {
const options = makeConnectOptions();
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_AWAITING_ACTIVATION, options });
expect(result).toBe(state);
});
it('ACCOUNT_ACTIVATION_SUCCESS → returns state unchanged', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_SUCCESS });
expect(result).toBe(state);
});
it('ACCOUNT_ACTIVATION_FAILED → returns state unchanged', () => {
const state = makeServerState();
const result = serverReducer(state, { type: Types.ACCOUNT_ACTIVATION_FAILED });
expect(result).toBe(state);
});
});
// ── 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 → 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 });
// IsUser(1) | IsRegistered(2) | IsModerator(4) | IsJudge(16) = 23
expect(result.users[0].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 });
// IsUser(1) | IsRegistered(2) | IsModerator(4) = 7
expect(result.users[0].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 });
// IsUser(1) | IsRegistered(2) | IsJudge(16) = 19
expect(result.users[0].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 });
// IsUser(1) | IsRegistered(2) = 3
expect(result.users[0].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 });
// IsUser(1) | IsRegistered(2) | IsModerator(4) = 7
expect(result.users[0].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);
});
});
// ── 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);
});
});
// ── GAMES_OF_USER ─────────────────────────────────────────────────────────────
describe('GAMES_OF_USER', () => {
it('stores games keyed by userName', () => {
const games = [{ gameId: 5, roomId: 1 }] as any;
const state = makeServerState();
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games });
expect(result.gamesOfUser['alice']).toBe(games);
});
it('overwrites previous games for same user', () => {
const old = [{ gameId: 1 }] as any;
const fresh = [{ gameId: 2 }] as any;
const state = makeServerState({ gamesOfUser: { alice: old } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games: fresh });
expect(result.gamesOfUser['alice']).toBe(fresh);
});
it('does not affect other users\' entries', () => {
const bobGames = [{ gameId: 3 }] as any;
const state = makeServerState({ gamesOfUser: { bob: bobGames } });
const result = serverReducer(state, { type: Types.GAMES_OF_USER, userName: 'alice', games: [] });
expect(result.gamesOfUser['bob']).toBe(bobGames);
});
});

View file

@ -80,7 +80,6 @@ const initialState: ServerState = {
field: UserSortField.NAME,
order: SortDirection.ASC
},
connectOptions: {},
messages: {},
userInfo: {},
notifications: [],
@ -93,6 +92,7 @@ const initialState: ServerState = {
adminNotes: {},
replays: [],
backendDecks: null,
gamesOfUser: {},
};
export const serverReducer = (state = initialState, action: any) => {
@ -104,19 +104,11 @@ export const serverReducer = (state = initialState, action: any) => {
}
}
case Types.ACCOUNT_AWAITING_ACTIVATION: {
return {
...state,
connectOptions: {
...action.options
}
}
return state;
}
case Types.ACCOUNT_ACTIVATION_FAILED:
case Types.ACCOUNT_ACTIVATION_SUCCESS: {
return {
...state,
connectOptions: {}
}
return state;
}
case Types.CLEAR_STORE: {
return {
@ -401,11 +393,12 @@ export const serverReducer = (state = initialState, action: any) => {
if (user.name !== userName) {
return user;
}
const judgeFlag = shouldBeJudge ? UserLevelFlag.IsJudge : UserLevelFlag.IsNothing;
const modFlag = shouldBeMod ? UserLevelFlag.IsModerator : UserLevelFlag.IsNothing;
let newLevel = user.userLevel;
newLevel = shouldBeMod ? (newLevel | UserLevelFlag.IsModerator) : (newLevel & ~UserLevelFlag.IsModerator);
newLevel = shouldBeJudge ? (newLevel | UserLevelFlag.IsJudge) : (newLevel & ~UserLevelFlag.IsJudge);
return {
...user,
userLevel: user.userLevel & (judgeFlag | modFlag)
userLevel: newLevel,
}
})
};
@ -475,6 +468,16 @@ export const serverReducer = (state = initialState, action: any) => {
},
};
}
case Types.GAMES_OF_USER: {
const { userName, games } = action;
return {
...state,
gamesOfUser: {
...state.gamesOfUser,
[userName]: games,
},
};
}
default:
return state;
}

View file

@ -0,0 +1,92 @@
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('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();
});
});

View file

@ -6,7 +6,6 @@ interface State {
export const Selectors = {
getInitialized: ({ server }: State) => server.initialized,
getConnectOptions: ({ server }: State) => server.connectOptions,
getMessage: ({ server }: State) => server.info.message,
getName: ({ server }: State) => server.info.name,
getVersion: ({ server }: State) => server.info.version,

View file

@ -23,7 +23,7 @@ export const Types = {
VIEW_LOGS: '[Server] View Logs',
CLEAR_LOGS: '[Server] Clear Logs',
REGISTRATION_REQUIRES_EMAIL: '[Server] Registration Requires Email',
REGISTRATION_SUCCES: '[Server] Registration Success',
REGISTRATION_SUCCESS: '[Server] Registration Success',
REGISTRATION_FAILED: '[Server] Registration Failed',
REGISTRATION_EMAIL_ERROR: '[Server] Registration Email Error',
REGISTRATION_PASSWORD_ERROR: '[Server] Registration Password Error',
@ -42,7 +42,6 @@ export const Types = {
ACCOUNT_PASSWORD_CHANGE: '[Server] Account Password Change',
ACCOUNT_EDIT_CHANGED: '[Server] Account Edit Changed',
ACCOUNT_IMAGE_CHANGED: '[Server] Account Image Changed',
DIRECT_MESSAGE_SENT: '[Server] Direct Message Sent',
GET_USER_INFO: '[Server] Get User Info',
NOTIFY_USER: '[Server] Notify User',
SERVER_SHUTDOWN: '[Server] Server Shutdown',
@ -69,4 +68,6 @@ export const Types = {
DECK_DEL_DIR: '[Server] Deck Del Dir',
DECK_UPLOAD: '[Server] Deck Upload',
DECK_DELETE: '[Server] Deck Delete',
// User games
GAMES_OF_USER: '[Server] Games Of User',
};

View file

@ -40,3 +40,474 @@ export enum LeaveGameReason {
USER_LEFT = 3,
USER_DISCONNECTED = 4
}
// ── Enums ────────────────────────────────────────────────────────────────────
export enum ZoneType {
PrivateZone = 0,
PublicZone = 1,
HiddenZone = 2,
}
/** Matches CardAttribute enum in card_attributes.proto */
export enum CardAttribute {
AttrTapped = 1,
AttrAttacking = 2,
AttrFaceDown = 3,
AttrColor = 4,
AttrPT = 5,
AttrAnnotation = 6,
AttrDoesntUntap = 7,
}
// ── Primitive data structures (mirrors ServerInfo_* protos) ──────────────────
export interface Color {
r: number;
g: number;
b: number;
a: number;
}
/** Mirrors ServerInfo_CardCounter */
export interface CardCounterInfo {
id: number;
value: number;
}
/** Mirrors ServerInfo_Card */
export interface CardInfo {
id: number;
name: string;
x: number;
y: number;
faceDown: boolean;
tapped: boolean;
attacking: boolean;
color: string;
pt: string;
annotation: string;
destroyOnZoneChange: boolean;
doesntUntap: boolean;
counterList: CardCounterInfo[];
attachPlayerId: number;
attachZone: string;
attachCardId: number;
providerId: string;
}
/** Mirrors ServerInfo_Zone */
export interface ZoneInfo {
name: string;
type: ZoneType;
withCoords: boolean;
cardCount: number;
cardList: CardInfo[];
alwaysRevealTopCard: boolean;
alwaysLookAtTopCard: boolean;
}
/** Mirrors ServerInfo_Counter */
export interface CounterInfo {
id: number;
name: string;
counterColor: Color;
radius: number;
count: number;
}
/** Mirrors ServerInfo_Arrow */
export interface ArrowInfo {
id: number;
startPlayerId: number;
startZone: string;
startCardId: number;
targetPlayerId: number;
targetZone: string;
targetCardId: number;
arrowColor: Color;
}
/** Mirrors ServerInfo_PlayerProperties */
export interface PlayerProperties {
playerId: number;
userInfo: any;
spectator: boolean;
conceded: boolean;
readyStart: boolean;
deckHash: string;
pingSeconds: number;
sideboardLocked: boolean;
judge: boolean;
}
/** Mirrors ServerInfo_Player */
export interface PlayerInfo {
properties: PlayerProperties;
deckList: string;
zoneList: ZoneInfo[];
counterList: CounterInfo[];
arrowList: ArrowInfo[];
}
// ── Game event payload interfaces (data arriving from server events) ──────────
export interface GameStateChangedData {
playerList: PlayerInfo[];
gameStarted: boolean;
activePlayerId: number;
activePhase: number;
secondsElapsed: number;
}
export interface GameSayData {
message: string;
}
export interface MoveCardData {
cardId: number;
cardName: string;
startPlayerId: number;
startZone: string;
position: number;
targetPlayerId: number;
targetZone: string;
x: number;
y: number;
newCardId: number;
faceDown: boolean;
newCardProviderId: string;
}
export interface FlipCardData {
zoneName: string;
cardId: number;
cardName: string;
faceDown: boolean;
cardProviderId: string;
}
export interface DestroyCardData {
zoneName: string;
cardId: number;
}
export interface AttachCardData {
startZone: string;
cardId: number;
targetPlayerId: number;
targetZone: string;
targetCardId: number;
}
export interface CreateTokenData {
zoneName: string;
cardId: number;
cardName: string;
color: string;
pt: string;
annotation: string;
destroyOnZoneChange: boolean;
x: number;
y: number;
cardProviderId: string;
faceDown: boolean;
}
export interface SetCardAttrData {
zoneName: string;
cardId: number;
attribute: CardAttribute;
attrValue: string;
}
export interface SetCardCounterData {
zoneName: string;
cardId: number;
counterId: number;
counterValue: number;
}
export interface CreateArrowData {
arrowInfo: ArrowInfo;
}
export interface DeleteArrowData {
arrowId: number;
}
export interface CreateCounterData {
counterInfo: CounterInfo;
}
export interface SetCounterData {
counterId: number;
value: number;
}
export interface DelCounterData {
counterId: number;
}
export interface DrawCardsData {
number: number;
cards: CardInfo[];
}
export interface RevealCardsData {
zoneName: string;
cardId: number[];
otherPlayerId: number;
cards: CardInfo[];
grantWriteAccess: boolean;
numberOfCards: number;
}
export interface ShuffleData {
zoneName: string;
start: number;
end: number;
}
export interface RollDieData {
sides: number;
value: number;
values: number[];
}
export interface DumpZoneData {
zoneOwnerId: number;
zoneName: string;
numberCards: number;
isReversed: boolean;
}
export interface ChangeZonePropertiesData {
zoneName: string;
alwaysRevealTopCard: boolean;
alwaysLookAtTopCard: boolean;
}
export interface SetActivePlayerData {
activePlayerId: number;
}
export interface SetActivePhaseData {
phase: number;
}
export interface ReverseTurnData {
reversed: boolean;
}
/**
* Passed to every game event handler alongside the event payload.
* Contains per-container metadata from GameEventContainer.
* Not stored in Redux transient routing metadata only.
*/
export interface GameEventContext {
'.Context_ReadyStart.ext'?: {};
'.Context_Concede.ext'?: {};
'.Context_DeckSelect.ext'?: {};
'.Context_UndoDraw.ext'?: {};
'.Context_MoveCard.ext'?: {};
'.Context_Mulligan.ext'?: {};
'.Context_PingChanged.ext'?: {};
'.Context_ConnectionStateChanged.ext'?: {};
'.Context_SetSideboardLock.ext'?: {};
'.Context_Unconcede.ext'?: {};
}
export interface GameEventMeta {
gameId: number;
playerId: number;
/** Raw protobuf GameEventContext object. Not stored in Redux. */
context: GameEventContext | null;
secondsElapsed: number;
/** Proto type is uint32. Non-zero means the action was forced by a judge. */
forcedByJudge: number;
}
// ── Command parameter interfaces ─────────────────────────────────────────────
export interface CardToMove {
cardId: number;
faceDown?: boolean;
pt?: string;
tapped?: boolean;
}
export interface MoveCardParams {
startPlayerId: number;
startZone: string;
cardsToMove: { card: CardToMove[] };
targetPlayerId: number;
targetZone: string;
x?: number;
y?: number;
isReversed?: boolean;
}
export interface DrawCardsParams {
number: number;
}
export interface RollDieParams {
sides: number;
count?: number;
}
export interface ShuffleParams {
zoneName: string;
start?: number;
end?: number;
}
export interface FlipCardParams {
zone: string;
cardId: number;
faceDown: boolean;
pt?: string;
}
export interface AttachCardParams {
startZone: string;
cardId: number;
targetPlayerId?: number;
targetZone?: string;
targetCardId?: number;
}
export interface CreateTokenParams {
zone: string;
cardName: string;
color?: string;
pt?: string;
annotation?: string;
destroyOnZoneChange?: boolean;
x?: number;
y?: number;
targetZone?: string;
targetCardId?: number;
targetMode?: number;
cardProviderId?: string;
faceDown?: boolean;
}
export interface SetCardAttrParams {
zone: string;
cardId: number;
attribute: CardAttribute;
attrValue: string;
}
export interface SetCardCounterParams {
zone: string;
cardId: number;
counterId: number;
counterValue: number;
}
export interface IncCardCounterParams {
zone: string;
cardId: number;
counterId: number;
counterDelta: number;
}
export interface RevealCardsParams {
zoneName: string;
cardId?: number[];
playerId?: number;
grantWriteAccess?: boolean;
topCards?: number;
}
export interface DumpZoneParams {
playerId: number;
zoneName: string;
numberCards: number;
isReversed?: boolean;
}
export interface ChangeZonePropertiesParams {
zoneName: string;
alwaysRevealTopCard?: boolean;
alwaysLookAtTopCard?: boolean;
}
export interface CreateArrowParams {
startPlayerId: number;
startZone: string;
startCardId: number;
targetPlayerId: number;
targetZone?: string;
targetCardId?: number;
arrowColor: Color;
deleteInPhase?: number;
}
export interface DeleteArrowParams {
arrowId: number;
}
export interface CreateCounterParams {
counterName: string;
counterColor: Color;
radius: number;
value: number;
}
export interface SetCounterParams {
counterId: number;
value: number;
}
export interface IncCounterParams {
counterId: number;
delta: number;
}
export interface DelCounterParams {
counterId: number;
}
export interface KickFromGameParams {
playerId: number;
}
export interface ReadyStartParams {
ready: boolean;
forceStart?: boolean;
}
export interface MulliganParams {
number: number;
}
export interface DeckSelectParams {
deck?: string;
deckId?: number;
}
export interface MoveCardToZone {
cardName: string;
startZone: string;
targetZone: string;
}
export interface SetSideboardPlanParams {
moveList: MoveCardToZone[];
}
export interface SetSideboardLockParams {
locked: boolean;
}
export interface SetActivePhaseParams {
phase: number;
}
export interface GameSayParams {
message: string;
}

2
webclient/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />

View file

@ -1,26 +1,28 @@
jest.mock('./services/WebSocketService', () => ({
WebSocketService: jest.fn().mockImplementation(() => ({
message$: { subscribe: jest.fn() },
connect: jest.fn(),
testConnect: jest.fn(),
disconnect: jest.fn(),
vi.mock('./services/WebSocketService', () => ({
WebSocketService: vi.fn().mockImplementation(() => ({
message$: { subscribe: vi.fn() },
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
})),
}));
jest.mock('./services/ProtobufService', () => ({
ProtobufService: jest.fn().mockImplementation(() => ({
handleMessageEvent: jest.fn(),
sendKeepAliveCommand: jest.fn(),
resetCommands: jest.fn(),
vi.mock('./services/ProtobufService', () => ({
ProtobufService: vi.fn().mockImplementation(() => ({
handleMessageEvent: vi.fn(),
sendKeepAliveCommand: vi.fn(),
resetCommands: vi.fn(),
})),
}));
jest.mock('./persistence', () => ({
RoomPersistence: { clearStore: jest.fn() },
SessionPersistence: { clearStore: jest.fn() },
vi.mock('./persistence', () => ({
RoomPersistence: { clearStore: vi.fn() },
SessionPersistence: { clearStore: vi.fn() },
}));
import { WebClient } from './WebClient';
import { WebSocketService } from './services/WebSocketService';
import { ProtobufService } from './services/ProtobufService';
import { RoomPersistence, SessionPersistence } from './persistence';
import { StatusEnum } from 'types';
import { Subject } from 'rxjs';
@ -30,28 +32,26 @@ describe('WebClient', () => {
let messageSubject: Subject<MessageEvent>;
beforeEach(() => {
jest.clearAllMocks();
const { ProtobufService } = require('./services/ProtobufService');
ProtobufService.mockImplementation(() => ({
handleMessageEvent: jest.fn(),
sendKeepAliveCommand: jest.fn(),
resetCommands: jest.fn(),
vi.clearAllMocks();
(ProtobufService as vi.Mock).mockImplementation(() => ({
handleMessageEvent: vi.fn(),
sendKeepAliveCommand: vi.fn(),
resetCommands: vi.fn(),
}));
messageSubject = new Subject<MessageEvent>();
const { WebSocketService } = require('./services/WebSocketService');
WebSocketService.mockImplementation(() => ({
(WebSocketService as vi.Mock).mockImplementation(() => ({
message$: messageSubject,
connect: jest.fn(),
testConnect: jest.fn(),
disconnect: jest.fn(),
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
}));
// suppress console.log from constructor in non-test-env check
jest.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
client = new WebClient();
});
afterEach(() => {
jest.restoreAllMocks();
vi.restoreAllMocks();
});
describe('constructor', () => {
@ -94,7 +94,7 @@ describe('WebClient', () => {
describe('keepAlive', () => {
it('delegates to protobuf.sendKeepAliveCommand', () => {
const pingCb = jest.fn();
const pingCb = vi.fn();
client.keepAlive(pingCb);
expect(client.protobuf.sendKeepAliveCommand).toHaveBeenCalledWith(pingCb);
});

View file

@ -3,6 +3,7 @@ import { StatusEnum, WebSocketConnectOptions } from 'types';
import { ProtobufService } from './services/ProtobufService';
import { WebSocketService } from './services/WebSocketService';
import { GameDispatch } from 'store';
import { RoomPersistence, SessionPersistence } from './persistence';
export class WebClient {
@ -46,7 +47,7 @@ export class WebClient {
this.protobuf.handleMessageEvent(message);
});
if (process.env.NODE_ENV !== 'test') {
if (import.meta.env.MODE !== 'test') {
console.log(this);
}
}
@ -79,6 +80,7 @@ export class WebClient {
}
private clearStores() {
GameDispatch.clearStore();
RoomPersistence.clearStore();
SessionPersistence.clearStore();
}

View file

@ -1,13 +1,13 @@
/**
* Factory for invoking BackendService command callbacks in unit tests.
*
* @param mockFn - The jest.Mock for the BackendService send method
* (e.g. BackendService.sendSessionCommand as jest.Mock).
* @param mockFn - The vi.Mock for the BackendService send method
* (e.g. BackendService.sendSessionCommand as vi.Mock).
* @param optsArgIndex - Index of the options argument in the mock call.
* Defaults to 2 (commandName, params, options).
* Use 3 for sendRoomCommand (roomId, commandName, params, options).
*/
export function makeCallbackHelpers(mockFn: jest.Mock, optsArgIndex = 2) {
export function makeCallbackHelpers(mockFn: vi.Mock, optsArgIndex = 2) {
function getLastSendOpts() {
const calls = mockFn.mock.calls;
return calls[calls.length - 1]?.[optsArgIndex];

View file

@ -6,18 +6,18 @@
/** Builds a minimal mock of ProtoController.root */
export function makeMockProtoRoot() {
const encode = { finish: jest.fn().mockReturnValue(new Uint8Array()) };
const encode = { finish: vi.fn().mockReturnValue(new Uint8Array()) };
return {
CommandContainer: {
create: jest.fn(args => ({ ...args })),
encode: jest.fn().mockReturnValue(encode),
create: vi.fn(args => ({ ...args })),
encode: vi.fn().mockReturnValue(encode),
},
SessionCommand: { create: jest.fn(args => ({ ...args })) },
RoomCommand: { create: jest.fn(args => ({ ...args })) },
ModeratorCommand: { create: jest.fn(args => ({ ...args })) },
AdminCommand: { create: jest.fn(args => ({ ...args })) },
SessionCommand: { create: vi.fn(args => ({ ...args })) },
RoomCommand: { create: vi.fn(args => ({ ...args })) },
ModeratorCommand: { create: vi.fn(args => ({ ...args })) },
AdminCommand: { create: vi.fn(args => ({ ...args })) },
ServerMessage: {
decode: jest.fn(),
decode: vi.fn(),
MessageType: {
RESPONSE: 'RESPONSE',
ROOM_EVENT: 'ROOM_EVENT',
@ -52,8 +52,8 @@ export function makeMockProtoRoot() {
/** Builds a mock WebSocket instance */
export function makeMockWebSocketInstance() {
return {
send: jest.fn(),
close: jest.fn(),
send: vi.fn(),
close: vi.fn(),
readyState: WebSocket.OPEN,
binaryType: '' as BinaryType,
onopen: null as any,
@ -66,7 +66,7 @@ export function makeMockWebSocketInstance() {
/** Installs a mock WebSocket constructor on global. Returns the mock instance. */
export function installMockWebSocket() {
const mockInstance = makeMockWebSocketInstance();
const MockWS = jest.fn(() => mockInstance) as any;
const MockWS = vi.fn(() => mockInstance) as any;
MockWS.OPEN = 1;
MockWS.CLOSED = 3;
(global as any).WebSocket = MockWS;

View file

@ -1,10 +1,10 @@
/**
* Shared mock shape factories for session command specs.
*
* Usage inside jest.mock() factory callbacks (require is used because
* jest.mock() is hoisted above imports):
* Usage inside vi.mock() factory callbacks (require is used because
* vi.mock() is hoisted above imports):
*
* jest.mock('../../WebClient', () => {
* vi.mock('../../WebClient', () => {
* const { makeWebClientMock } = require('../../__mocks__/sessionCommandMocks');
* return { __esModule: true, default: makeWebClientMock() };
* });
@ -13,10 +13,10 @@
/** Superset WebClient mock — covers all properties used across both session spec files. */
export function makeWebClientMock() {
return {
connect: jest.fn(),
testConnect: jest.fn(),
disconnect: jest.fn(),
updateStatus: jest.fn(),
connect: vi.fn(),
testConnect: vi.fn(),
disconnect: vi.fn(),
updateStatus: vi.fn(),
clientConfig: { clientid: 'webatrice', clientver: '1.0', clientfeatures: [] },
options: {},
protocolVersion: 14,
@ -60,73 +60,72 @@ export function makeProtoControllerRootMock() {
/** Utils mock with unified return values. */
export function makeUtilsMock() {
return {
hashPassword: jest.fn().mockReturnValue('hashed_pw'),
generateSalt: jest.fn().mockReturnValue('randSalt'),
passwordSaltSupported: jest.fn().mockReturnValue(0),
hashPassword: vi.fn().mockReturnValue('hashed_pw'),
generateSalt: vi.fn().mockReturnValue('randSalt'),
passwordSaltSupported: vi.fn().mockReturnValue(0),
};
}
/** Superset SessionPersistence mock — covers all methods used across both session spec files. */
export function makeSessionPersistenceMock() {
return {
loginSuccessful: jest.fn(),
loginFailed: jest.fn(),
updateBuddyList: jest.fn(),
updateIgnoreList: jest.fn(),
updateUser: jest.fn(),
updateUsers: jest.fn(),
accountAwaitingActivation: jest.fn(),
accountActivationSuccess: jest.fn(),
accountActivationFailed: jest.fn(),
updateStatus: jest.fn(),
directMessageSent: jest.fn(),
addToList: jest.fn(),
removeFromList: jest.fn(),
deleteServerDeck: jest.fn(),
deleteServerDeckDir: jest.fn(),
updateServerDecks: jest.fn(),
uploadServerDeck: jest.fn(),
createServerDeckDir: jest.fn(),
getGamesOfUser: jest.fn(),
getUserInfo: jest.fn(),
accountPasswordChange: jest.fn(),
accountEditChanged: jest.fn(),
accountImageChanged: jest.fn(),
replayList: jest.fn(),
replayAdded: jest.fn(),
replayModifyMatch: jest.fn(),
replayDeleteMatch: jest.fn(),
resetPasswordChallenge: jest.fn(),
resetPassword: jest.fn(),
resetPasswordFailed: jest.fn(),
resetPasswordSuccess: jest.fn(),
registrationFailed: jest.fn(),
registrationSuccess: jest.fn(),
registrationUserNameError: jest.fn(),
registrationPasswordError: jest.fn(),
registrationEmailError: jest.fn(),
registrationRequiresEmail: jest.fn(),
loginSuccessful: vi.fn(),
loginFailed: vi.fn(),
updateBuddyList: vi.fn(),
updateIgnoreList: vi.fn(),
updateUser: vi.fn(),
updateUsers: vi.fn(),
accountAwaitingActivation: vi.fn(),
accountActivationSuccess: vi.fn(),
accountActivationFailed: vi.fn(),
updateStatus: vi.fn(),
addToList: vi.fn(),
removeFromList: vi.fn(),
deleteServerDeck: vi.fn(),
deleteServerDeckDir: vi.fn(),
updateServerDecks: vi.fn(),
uploadServerDeck: vi.fn(),
createServerDeckDir: vi.fn(),
getGamesOfUser: vi.fn(),
getUserInfo: vi.fn(),
accountPasswordChange: vi.fn(),
accountEditChanged: vi.fn(),
accountImageChanged: vi.fn(),
replayList: vi.fn(),
replayAdded: vi.fn(),
replayModifyMatch: vi.fn(),
replayDeleteMatch: vi.fn(),
resetPasswordChallenge: vi.fn(),
resetPassword: vi.fn(),
resetPasswordFailed: vi.fn(),
resetPasswordSuccess: vi.fn(),
registrationFailed: vi.fn(),
registrationSuccess: vi.fn(),
registrationUserNameError: vi.fn(),
registrationPasswordError: vi.fn(),
registrationEmailError: vi.fn(),
registrationRequiresEmail: vi.fn(),
};
}
/**
* Session barrel mock pure jest.fn() map for all cross-command calls.
* Session barrel mock pure vi.fn() map for all cross-command calls.
* Used as-is by sessionCommands-complex.spec.ts, or spread over jest.requireActual
* by sessionCommands-simple.spec.ts to preserve real implementations for
* the commands under test.
*/
export function makeSessionBarrelMock() {
return {
login: jest.fn(),
register: jest.fn(),
activate: jest.fn(),
forgotPasswordReset: jest.fn(),
forgotPasswordRequest: jest.fn(),
forgotPasswordChallenge: jest.fn(),
requestPasswordSalt: jest.fn(),
listUsers: jest.fn(),
listRooms: jest.fn(),
updateStatus: jest.fn(),
disconnect: jest.fn(),
login: vi.fn(),
register: vi.fn(),
activate: vi.fn(),
forgotPasswordReset: vi.fn(),
forgotPasswordRequest: vi.fn(),
forgotPasswordChallenge: vi.fn(),
requestPasswordSalt: vi.fn(),
listUsers: vi.fn(),
listRooms: vi.fn(),
updateStatus: vi.fn(),
disconnect: vi.fn(),
};
}

View file

@ -1,33 +1,36 @@
jest.mock('../../services/BackendService', () => ({
vi.mock('../../services/BackendService', () => ({
BackendService: {
sendAdminCommand: jest.fn(),
sendAdminCommand: vi.fn(),
},
}));
jest.mock('../../persistence', () => ({
vi.mock('../../persistence', () => ({
AdminPersistence: {
adjustMod: jest.fn(),
reloadConfig: jest.fn(),
shutdownServer: jest.fn(),
updateServerMessage: jest.fn(),
adjustMod: vi.fn(),
reloadConfig: vi.fn(),
shutdownServer: vi.fn(),
updateServerMessage: vi.fn(),
},
}));
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { BackendService } from '../../services/BackendService';
import { AdminPersistence } from '../../persistence';
import { adjustMod } from './adjustMod';
import { reloadConfig } from './reloadConfig';
import { shutdownServer } from './shutdownServer';
import { updateServerMessage } from './updateServerMessage';
const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendAdminCommand as jest.Mock
BackendService.sendAdminCommand as vi.Mock
);
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
// ----------------------------------------------------------------
// adjustMod
// ----------------------------------------------------------------
describe('adjustMod', () => {
const { adjustMod } = jest.requireActual('./adjustMod');
it('calls sendAdminCommand with Command_AdjustMod', () => {
adjustMod('alice', true, false);
@ -49,7 +52,6 @@ describe('adjustMod', () => {
// reloadConfig
// ----------------------------------------------------------------
describe('reloadConfig', () => {
const { reloadConfig } = jest.requireActual('./reloadConfig');
it('calls sendAdminCommand with Command_ReloadConfig', () => {
reloadConfig();
@ -67,7 +69,6 @@ describe('reloadConfig', () => {
// shutdownServer
// ----------------------------------------------------------------
describe('shutdownServer', () => {
const { shutdownServer } = jest.requireActual('./shutdownServer');
it('calls sendAdminCommand with Command_ShutdownServer', () => {
shutdownServer('maintenance', 10);
@ -89,7 +90,6 @@ describe('shutdownServer', () => {
// updateServerMessage
// ----------------------------------------------------------------
describe('updateServerMessage', () => {
const { updateServerMessage } = jest.requireActual('./updateServerMessage');
it('calls sendAdminCommand with Command_UpdateServerMessage', () => {
updateServerMessage();

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { AttachCardParams } from 'types';
export function attachCard(gameId: number, params: AttachCardParams): void {
BackendService.sendGameCommand(gameId, 'Command_AttachCard', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { ChangeZonePropertiesParams } from 'types';
export function changeZoneProperties(gameId: number, params: ChangeZonePropertiesParams): void {
BackendService.sendGameCommand(gameId, 'Command_ChangeZoneProperties', params);
}

View file

@ -0,0 +1,5 @@
import { BackendService } from '../../services/BackendService';
export function concede(gameId: number): void {
BackendService.sendGameCommand(gameId, 'Command_Concede', {});
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { CreateArrowParams } from 'types';
export function createArrow(gameId: number, params: CreateArrowParams): void {
BackendService.sendGameCommand(gameId, 'Command_CreateArrow', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { CreateCounterParams } from 'types';
export function createCounter(gameId: number, params: CreateCounterParams): void {
BackendService.sendGameCommand(gameId, 'Command_CreateCounter', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { CreateTokenParams } from 'types';
export function createToken(gameId: number, params: CreateTokenParams): void {
BackendService.sendGameCommand(gameId, 'Command_CreateToken', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { DeckSelectParams } from 'types';
export function deckSelect(gameId: number, params: DeckSelectParams): void {
BackendService.sendGameCommand(gameId, 'Command_DeckSelect', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { DelCounterParams } from 'types';
export function delCounter(gameId: number, params: DelCounterParams): void {
BackendService.sendGameCommand(gameId, 'Command_DelCounter', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { DeleteArrowParams } from 'types';
export function deleteArrow(gameId: number, params: DeleteArrowParams): void {
BackendService.sendGameCommand(gameId, 'Command_DeleteArrow', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { DrawCardsParams } from 'types';
export function drawCards(gameId: number, params: DrawCardsParams): void {
BackendService.sendGameCommand(gameId, 'Command_DrawCards', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { DumpZoneParams } from 'types';
export function dumpZone(gameId: number, params: DumpZoneParams): void {
BackendService.sendGameCommand(gameId, 'Command_DumpZone', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { FlipCardParams } from 'types';
export function flipCard(gameId: number, params: FlipCardParams): void {
BackendService.sendGameCommand(gameId, 'Command_FlipCard', params);
}

View file

@ -0,0 +1,217 @@
import { BackendService } from '../../services/BackendService';
import { attachCard } from './attachCard';
import { changeZoneProperties } from './changeZoneProperties';
import { concede } from './concede';
import { createArrow } from './createArrow';
import { createCounter } from './createCounter';
import { createToken } from './createToken';
import { deckSelect } from './deckSelect';
import { delCounter } from './delCounter';
import { deleteArrow } from './deleteArrow';
import { drawCards } from './drawCards';
import { dumpZone } from './dumpZone';
import { flipCard } from './flipCard';
import { gameSay } from './gameSay';
import { incCardCounter } from './incCardCounter';
import { incCounter } from './incCounter';
import { kickFromGame } from './kickFromGame';
import { leaveGame } from './leaveGame';
import { moveCard } from './moveCard';
import { mulligan } from './mulligan';
import { nextTurn } from './nextTurn';
import { readyStart } from './readyStart';
import { revealCards } from './revealCards';
import { reverseTurn } from './reverseTurn';
import { setActivePhase } from './setActivePhase';
import { setCardAttr } from './setCardAttr';
import { setCardCounter } from './setCardCounter';
import { setCounter } from './setCounter';
import { setSideboardLock } from './setSideboardLock';
import { setSideboardPlan } from './setSideboardPlan';
import { shuffle } from './shuffle';
import { undoDraw } from './undoDraw';
import { unconcede } from './unconcede';
import { judge } from './judge';
vi.mock('../../services/BackendService', () => ({
BackendService: { sendGameCommand: vi.fn() },
}));
const gameId = 1;
const params = {} as any;
beforeEach(() => {
(BackendService.sendGameCommand as vi.Mock).mockClear();
});
describe('Game commands — delegate to BackendService.sendGameCommand', () => {
it('attachCard sends Command_AttachCard', () => {
attachCard(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_AttachCard', params);
});
it('changeZoneProperties sends Command_ChangeZoneProperties', () => {
changeZoneProperties(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_ChangeZoneProperties', params);
});
it('concede sends Command_Concede with empty object', () => {
concede(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Concede', {});
});
it('createArrow sends Command_CreateArrow', () => {
createArrow(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_CreateArrow', params);
});
it('createCounter sends Command_CreateCounter', () => {
createCounter(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_CreateCounter', params);
});
it('createToken sends Command_CreateToken', () => {
createToken(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_CreateToken', params);
});
it('deckSelect sends Command_DeckSelect', () => {
deckSelect(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DeckSelect', params);
});
it('delCounter sends Command_DelCounter', () => {
delCounter(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DelCounter', params);
});
it('deleteArrow sends Command_DeleteArrow', () => {
deleteArrow(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DeleteArrow', params);
});
it('drawCards sends Command_DrawCards', () => {
drawCards(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DrawCards', params);
});
it('dumpZone sends Command_DumpZone', () => {
dumpZone(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_DumpZone', params);
});
it('flipCard sends Command_FlipCard', () => {
flipCard(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_FlipCard', params);
});
it('gameSay sends Command_GameSay', () => {
gameSay(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_GameSay', params);
});
it('incCardCounter sends Command_IncCardCounter', () => {
incCardCounter(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_IncCardCounter', params);
});
it('incCounter sends Command_IncCounter', () => {
incCounter(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_IncCounter', params);
});
it('kickFromGame sends Command_KickFromGame', () => {
kickFromGame(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_KickFromGame', params);
});
it('leaveGame sends Command_LeaveGame with empty object', () => {
leaveGame(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_LeaveGame', {});
});
it('moveCard sends Command_MoveCard', () => {
moveCard(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_MoveCard', params);
});
it('mulligan sends Command_Mulligan', () => {
mulligan(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Mulligan', params);
});
it('nextTurn sends Command_NextTurn with empty object', () => {
nextTurn(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_NextTurn', {});
});
it('readyStart sends Command_ReadyStart', () => {
readyStart(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_ReadyStart', params);
});
it('revealCards sends Command_RevealCards', () => {
revealCards(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_RevealCards', params);
});
it('reverseTurn sends Command_ReverseTurn with empty object', () => {
reverseTurn(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_ReverseTurn', {});
});
it('setActivePhase sends Command_SetActivePhase', () => {
setActivePhase(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetActivePhase', params);
});
it('setCardAttr sends Command_SetCardAttr', () => {
setCardAttr(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetCardAttr', params);
});
it('setCardCounter sends Command_SetCardCounter', () => {
setCardCounter(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetCardCounter', params);
});
it('setCounter sends Command_SetCounter', () => {
setCounter(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetCounter', params);
});
it('setSideboardLock sends Command_SetSideboardLock', () => {
setSideboardLock(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetSideboardLock', params);
});
it('setSideboardPlan sends Command_SetSideboardPlan', () => {
setSideboardPlan(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_SetSideboardPlan', params);
});
it('shuffle sends Command_Shuffle', () => {
shuffle(gameId, params);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Shuffle', params);
});
it('undoDraw sends Command_UndoDraw with empty object', () => {
undoDraw(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_UndoDraw', {});
});
it('unconcede sends Command_Unconcede with empty object', () => {
unconcede(gameId);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Unconcede', {});
});
it('judge sends Command_Judge with targetId and wrapped gameCommand array', () => {
const targetId = 3;
const innerGameCommand = { '.Command_DrawCards.ext': { numberOfCards: 2 } };
judge(gameId, targetId, innerGameCommand);
expect(BackendService.sendGameCommand).toHaveBeenCalledWith(gameId, 'Command_Judge', {
targetId,
gameCommand: [innerGameCommand],
});
});
});

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { GameSayParams } from 'types';
export function gameSay(gameId: number, params: GameSayParams): void {
BackendService.sendGameCommand(gameId, 'Command_GameSay', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { IncCardCounterParams } from 'types';
export function incCardCounter(gameId: number, params: IncCardCounterParams): void {
BackendService.sendGameCommand(gameId, 'Command_IncCardCounter', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { IncCounterParams } from 'types';
export function incCounter(gameId: number, params: IncCounterParams): void {
BackendService.sendGameCommand(gameId, 'Command_IncCounter', params);
}

View file

@ -0,0 +1,33 @@
export { leaveGame } from './leaveGame';
export { kickFromGame } from './kickFromGame';
export { gameSay } from './gameSay';
export { readyStart } from './readyStart';
export { concede } from './concede';
export { unconcede } from './unconcede';
export { judge } from './judge';
export { nextTurn } from './nextTurn';
export { setActivePhase } from './setActivePhase';
export { reverseTurn } from './reverseTurn';
export { moveCard } from './moveCard';
export { flipCard } from './flipCard';
export { attachCard } from './attachCard';
export { createToken } from './createToken';
export { setCardAttr } from './setCardAttr';
export { setCardCounter } from './setCardCounter';
export { incCardCounter } from './incCardCounter';
export { drawCards } from './drawCards';
export { undoDraw } from './undoDraw';
export { createArrow } from './createArrow';
export { deleteArrow } from './deleteArrow';
export { createCounter } from './createCounter';
export { setCounter } from './setCounter';
export { incCounter } from './incCounter';
export { delCounter } from './delCounter';
export { shuffle } from './shuffle';
export { dumpZone } from './dumpZone';
export { revealCards } from './revealCards';
export { changeZoneProperties } from './changeZoneProperties';
export { deckSelect } from './deckSelect';
export { setSideboardPlan } from './setSideboardPlan';
export { setSideboardLock } from './setSideboardLock';
export { mulligan } from './mulligan';

View file

@ -0,0 +1,8 @@
import { BackendService } from '../../services/BackendService';
export function judge(gameId: number, targetId: number, innerGameCommand: any): void {
BackendService.sendGameCommand(gameId, 'Command_Judge', {
targetId,
gameCommand: [innerGameCommand],
});
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { KickFromGameParams } from 'types';
export function kickFromGame(gameId: number, params: KickFromGameParams): void {
BackendService.sendGameCommand(gameId, 'Command_KickFromGame', params);
}

View file

@ -0,0 +1,5 @@
import { BackendService } from '../../services/BackendService';
export function leaveGame(gameId: number): void {
BackendService.sendGameCommand(gameId, 'Command_LeaveGame', {});
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { MoveCardParams } from 'types';
export function moveCard(gameId: number, params: MoveCardParams): void {
BackendService.sendGameCommand(gameId, 'Command_MoveCard', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { MulliganParams } from 'types';
export function mulligan(gameId: number, params: MulliganParams): void {
BackendService.sendGameCommand(gameId, 'Command_Mulligan', params);
}

View file

@ -0,0 +1,5 @@
import { BackendService } from '../../services/BackendService';
export function nextTurn(gameId: number): void {
BackendService.sendGameCommand(gameId, 'Command_NextTurn', {});
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { ReadyStartParams } from 'types';
export function readyStart(gameId: number, params: ReadyStartParams): void {
BackendService.sendGameCommand(gameId, 'Command_ReadyStart', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { RevealCardsParams } from 'types';
export function revealCards(gameId: number, params: RevealCardsParams): void {
BackendService.sendGameCommand(gameId, 'Command_RevealCards', params);
}

View file

@ -0,0 +1,5 @@
import { BackendService } from '../../services/BackendService';
export function reverseTurn(gameId: number): void {
BackendService.sendGameCommand(gameId, 'Command_ReverseTurn', {});
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { SetActivePhaseParams } from 'types';
export function setActivePhase(gameId: number, params: SetActivePhaseParams): void {
BackendService.sendGameCommand(gameId, 'Command_SetActivePhase', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { SetCardAttrParams } from 'types';
export function setCardAttr(gameId: number, params: SetCardAttrParams): void {
BackendService.sendGameCommand(gameId, 'Command_SetCardAttr', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { SetCardCounterParams } from 'types';
export function setCardCounter(gameId: number, params: SetCardCounterParams): void {
BackendService.sendGameCommand(gameId, 'Command_SetCardCounter', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { SetCounterParams } from 'types';
export function setCounter(gameId: number, params: SetCounterParams): void {
BackendService.sendGameCommand(gameId, 'Command_SetCounter', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { SetSideboardLockParams } from 'types';
export function setSideboardLock(gameId: number, params: SetSideboardLockParams): void {
BackendService.sendGameCommand(gameId, 'Command_SetSideboardLock', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { SetSideboardPlanParams } from 'types';
export function setSideboardPlan(gameId: number, params: SetSideboardPlanParams): void {
BackendService.sendGameCommand(gameId, 'Command_SetSideboardPlan', params);
}

View file

@ -0,0 +1,6 @@
import { BackendService } from '../../services/BackendService';
import { ShuffleParams } from 'types';
export function shuffle(gameId: number, params: ShuffleParams): void {
BackendService.sendGameCommand(gameId, 'Command_Shuffle', params);
}

View file

@ -0,0 +1,5 @@
import { BackendService } from '../../services/BackendService';
export function unconcede(gameId: number): void {
BackendService.sendGameCommand(gameId, 'Command_Unconcede', {});
}

View file

@ -0,0 +1,5 @@
import { BackendService } from '../../services/BackendService';
export function undoDraw(gameId: number): void {
BackendService.sendGameCommand(gameId, 'Command_UndoDraw', {});
}

View file

@ -1,4 +1,5 @@
export * as AdminCommands from './admin';
export * as GameCommands from './game';
export * as ModeratorCommands from './moderator';
export * as RoomCommands from './room';
export * as SessionCommands from './session';

View file

@ -1,39 +1,48 @@
jest.mock('../../services/BackendService', () => ({
vi.mock('../../services/BackendService', () => ({
BackendService: {
sendModeratorCommand: jest.fn(),
sendModeratorCommand: vi.fn(),
},
}));
jest.mock('../../persistence', () => ({
vi.mock('../../persistence', () => ({
ModeratorPersistence: {
banFromServer: jest.fn(),
forceActivateUser: jest.fn(),
getAdminNotes: jest.fn(),
banHistory: jest.fn(),
warnHistory: jest.fn(),
warnListOptions: jest.fn(),
grantReplayAccess: jest.fn(),
updateAdminNotes: jest.fn(),
viewLogs: jest.fn(),
warnUser: jest.fn(),
banFromServer: vi.fn(),
forceActivateUser: vi.fn(),
getAdminNotes: vi.fn(),
banHistory: vi.fn(),
warnHistory: vi.fn(),
warnListOptions: vi.fn(),
grantReplayAccess: vi.fn(),
updateAdminNotes: vi.fn(),
viewLogs: vi.fn(),
warnUser: vi.fn(),
},
}));
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { BackendService } from '../../services/BackendService';
import { ModeratorPersistence } from '../../persistence';
import { banFromServer } from './banFromServer';
import { forceActivateUser } from './forceActivateUser';
import { getAdminNotes } from './getAdminNotes';
import { getBanHistory } from './getBanHistory';
import { getWarnHistory } from './getWarnHistory';
import { getWarnList } from './getWarnList';
import { grantReplayAccess } from './grantReplayAccess';
import { updateAdminNotes } from './updateAdminNotes';
import { viewLogHistory } from './viewLogHistory';
import { warnUser } from './warnUser';
const { getLastSendOpts, invokeOnSuccess } = makeCallbackHelpers(
BackendService.sendModeratorCommand as jest.Mock
BackendService.sendModeratorCommand as vi.Mock
);
beforeEach(() => jest.clearAllMocks());
beforeEach(() => vi.clearAllMocks());
// ----------------------------------------------------------------
// banFromServer
// ----------------------------------------------------------------
describe('banFromServer', () => {
const { banFromServer } = jest.requireActual('./banFromServer');
it('calls sendModeratorCommand with Command_BanFromServer', () => {
banFromServer(30, 'alice', '1.2.3.4', 'reason', 'visible', 'cid', 1);
@ -55,7 +64,6 @@ describe('banFromServer', () => {
// forceActivateUser
// ----------------------------------------------------------------
describe('forceActivateUser', () => {
const { forceActivateUser } = jest.requireActual('./forceActivateUser');
it('calls sendModeratorCommand with Command_ForceActivateUser', () => {
forceActivateUser('alice', 'mod1');
@ -73,7 +81,6 @@ describe('forceActivateUser', () => {
// getAdminNotes
// ----------------------------------------------------------------
describe('getAdminNotes', () => {
const { getAdminNotes } = jest.requireActual('./getAdminNotes');
it('calls sendModeratorCommand with Command_GetAdminNotes', () => {
getAdminNotes('alice');
@ -96,7 +103,6 @@ describe('getAdminNotes', () => {
// getBanHistory
// ----------------------------------------------------------------
describe('getBanHistory', () => {
const { getBanHistory } = jest.requireActual('./getBanHistory');
it('calls sendModeratorCommand with Command_GetBanHistory', () => {
getBanHistory('alice');
@ -119,7 +125,6 @@ describe('getBanHistory', () => {
// getWarnHistory
// ----------------------------------------------------------------
describe('getWarnHistory', () => {
const { getWarnHistory } = jest.requireActual('./getWarnHistory');
it('calls sendModeratorCommand with Command_GetWarnHistory', () => {
getWarnHistory('alice');
@ -142,7 +147,6 @@ describe('getWarnHistory', () => {
// getWarnList
// ----------------------------------------------------------------
describe('getWarnList', () => {
const { getWarnList } = jest.requireActual('./getWarnList');
it('calls sendModeratorCommand with Command_GetWarnList', () => {
getWarnList('mod1', 'alice', 'US');
@ -165,7 +169,6 @@ describe('getWarnList', () => {
// grantReplayAccess
// ----------------------------------------------------------------
describe('grantReplayAccess', () => {
const { grantReplayAccess } = jest.requireActual('./grantReplayAccess');
it('calls sendModeratorCommand with Command_GrantReplayAccess', () => {
grantReplayAccess(10, 'mod1');
@ -183,7 +186,6 @@ describe('grantReplayAccess', () => {
// updateAdminNotes
// ----------------------------------------------------------------
describe('updateAdminNotes', () => {
const { updateAdminNotes } = jest.requireActual('./updateAdminNotes');
it('calls sendModeratorCommand with Command_UpdateAdminNotes', () => {
updateAdminNotes('alice', 'new notes');
@ -201,7 +203,6 @@ describe('updateAdminNotes', () => {
// viewLogHistory
// ----------------------------------------------------------------
describe('viewLogHistory', () => {
const { viewLogHistory } = jest.requireActual('./viewLogHistory');
it('calls sendModeratorCommand with Command_ViewLogHistory', () => {
viewLogHistory({ filters: 'all' } as any);
@ -224,7 +225,6 @@ describe('viewLogHistory', () => {
// warnUser
// ----------------------------------------------------------------
describe('warnUser', () => {
const { warnUser } = jest.requireActual('./warnUser');
it('calls sendModeratorCommand with Command_WarnUser', () => {
warnUser('alice', 'bad behavior', 'cid');

Some files were not shown because too many files have changed in this diff Show more