This commit is contained in:
seavor 2026-04-18 01:36:37 -05:00
parent d04aa83258
commit dcd6dc00f4
83 changed files with 1797 additions and 390 deletions

View file

@ -1 +1,2 @@
# Future template for server admin configuration
NODE_OPTIONS=--max-old-space-size=8192

View file

@ -1 +1,4 @@
export { withMockLocation, withEventRegistry } from './globalGuards';
export { renderWithProviders } from './renderWithProviders';
export { createMockWebClient } from './mockWebClient';
export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures';

View file

@ -0,0 +1,56 @@
import type { WebClient } from '@app/websocket';
/**
* Creates a mock WebClient whose `request` property has vi.fn() stubs
* for every service method that containers/forms call. Inject this into
* tests via `renderWithProviders({ webClient: createMockWebClient() })`.
*/
export function createMockWebClient() {
return {
request: {
authentication: {
login: vi.fn(),
register: vi.fn(),
disconnect: vi.fn(),
activateAccount: vi.fn(),
resetPasswordRequest: vi.fn(),
resetPasswordChallenge: vi.fn(),
resetPassword: vi.fn(),
},
session: {
addToBuddyList: vi.fn(),
removeFromBuddyList: vi.fn(),
addToIgnoreList: vi.fn(),
removeFromIgnoreList: vi.fn(),
getUserInfo: vi.fn(),
accountEdit: vi.fn(),
accountPassword: vi.fn(),
accountImage: vi.fn(),
listUsers: vi.fn(),
},
rooms: {
joinRoom: vi.fn(),
leaveRoom: vi.fn(),
roomSay: vi.fn(),
createGame: vi.fn(),
},
game: {
joinGame: vi.fn(),
leaveGame: vi.fn(),
},
admin: {
adjustMod: vi.fn(),
reloadConfig: vi.fn(),
shutdownServer: vi.fn(),
updateServerMessage: vi.fn(),
},
moderator: {
viewLogHistory: vi.fn(),
banFromServer: vi.fn(),
warnUser: vi.fn(),
warnHistory: vi.fn(),
banHistory: vi.fn(),
},
},
} as unknown as WebClient;
}

View file

@ -0,0 +1,71 @@
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { configureStore, EnhancedStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { gamesReducer } from '../store/game';
import { roomsReducer } from '../store/rooms';
import { serverReducer } from '../store/server';
import { actionReducer } from '../store/actions';
import { ToastProvider } from '../components/Toast/ToastContext';
import type { RootState } from '../store/store';
// Minimal i18n instance for tests — returns keys as-is.
const testI18n = i18n.createInstance();
testI18n.use(initReactI18next).init({
lng: 'en',
resources: {},
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
function createTestStore(preloadedState?: Partial<RootState>) {
return configureStore({
reducer: {
games: gamesReducer,
rooms: roomsReducer,
server: serverReducer,
action: actionReducer,
},
preloadedState: preloadedState as any,
});
}
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: Partial<RootState>;
store?: EnhancedStore;
route?: string;
}
export function renderWithProviders(
ui: ReactElement,
{
preloadedState,
store = createTestStore(preloadedState),
route = '/',
...renderOptions
}: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<Provider store={store}>
<I18nextProvider i18n={testI18n}>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>
{children}
</MemoryRouter>
</ToastProvider>
</I18nextProvider>
</Provider>
);
}
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}

View file

@ -0,0 +1,123 @@
import { App, Data, Enriched } from '@app/types';
import type { RootState } from '../store/store';
/**
* Create a minimal ServerInfo_User object for testing.
*/
function makeUser(overrides: Partial<Data.ServerInfo_User> = {}): Data.ServerInfo_User {
return {
name: 'testUser',
realName: '',
country: 'us',
userLevel: 0,
avatarBmp: new Uint8Array(),
accountageSecs: BigInt(0),
$typeName: 'ServerInfo_User' as any,
$unknown: undefined,
gender: 0,
...overrides,
} as Data.ServerInfo_User;
}
/**
* A disconnected (default) store state. This is the state before any
* connection to a server has been made.
*/
export const disconnectedState: Partial<RootState> = {
server: {
initialized: false,
buddyList: {},
ignoreList: {},
status: {
connectionAttemptMade: false,
state: Enriched.StatusEnum.DISCONNECTED,
description: null,
},
info: { message: null, name: null, version: null },
logs: { room: [], game: [], chat: [] },
user: null,
users: {},
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
messages: {},
userInfo: {},
notifications: [],
serverShutdown: null,
banUser: '',
banHistory: {},
warnHistory: {},
warnListOptions: [],
warnUser: '',
adminNotes: {},
replays: {},
backendDecks: null,
downloadedDeck: null,
downloadedReplay: null,
gamesOfUser: {},
registrationError: null,
},
rooms: {
rooms: {},
joinedRoomIds: {},
joinedGameIds: {},
messages: {},
sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC },
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
},
games: { games: {} },
action: { type: null, payload: null, meta: null, error: false, count: 0 },
};
/**
* A connected (logged-in) store state with a basic user and server info.
*/
export const connectedState: Partial<RootState> = {
...disconnectedState,
server: {
...(disconnectedState.server as any),
initialized: true,
status: {
connectionAttemptMade: true,
state: Enriched.StatusEnum.LOGGED_IN,
description: null,
},
info: {
message: '<b>Welcome</b>',
name: 'Test Server',
version: '1.0.0',
},
user: makeUser(),
users: {
testUser: makeUser(),
},
},
};
/**
* Connected state with rooms and a joined room containing games and users.
*/
export const connectedWithRoomsState: Partial<RootState> = {
...connectedState,
server: {
...(connectedState.server as any),
users: {
testUser: makeUser(),
otherUser: makeUser({ name: 'otherUser' }),
},
},
rooms: {
...(disconnectedState.rooms as any),
rooms: {
1: {
info: { roomId: 1, name: 'Main Room', description: 'The main room', autoJoin: true, permissionLevel: 0 },
gameList: [],
userList: [makeUser(), makeUser({ name: 'otherUser' })],
},
},
joinedRoomIds: { 1: true },
messages: {
1: [],
},
},
};
export { makeUser };

View file

@ -35,8 +35,8 @@ export class GameResponseImpl implements IGameResponse {
GameDispatch.kicked(gameId);
}
gameSay(gameId: number, playerId: number, message: string): void {
GameDispatch.gameSay(gameId, playerId, message);
gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void {
GameDispatch.gameSay(gameId, playerId, message, timeReceived);
}
cardMoved(gameId: number, playerId: number, data: Data.Event_MoveCard): void {

View file

@ -0,0 +1,33 @@
import { screen } from '@testing-library/react';
import { renderWithProviders, connectedState, disconnectedState } from '../../__test-utils__';
import AuthGuard from './AuthGuard';
vi.mock('@app/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/hooks')>();
return { ...actual, useWebClient: vi.fn(() => ({})) };
});
describe('AuthGuard', () => {
it('redirects to LOGIN when disconnected', () => {
renderWithProviders(<AuthGuard />, {
preloadedState: disconnectedState,
route: '/server',
});
// Navigate triggers a route change — AuthGuard itself renders no text.
// We verify it doesn't render any meaningful content.
expect(screen.queryByRole('button')).toBeNull();
expect(screen.queryByRole('heading')).toBeNull();
});
it('renders nothing visible when connected', () => {
const { container } = renderWithProviders(<AuthGuard />, {
preloadedState: connectedState,
route: '/server',
});
// AuthGuard renders an empty fragment when connected.
// The only DOM is from provider wrappers (e.g. ToastProvider's container div).
expect(container.textContent).toBe('');
});
});

View file

@ -8,7 +8,7 @@ const AuthGuard = () => {
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
return !isConnected
? <Navigate to={App.RouteEnum.LOGIN} />
: <div></div>;
: <></>;
};
export default AuthGuard;

View file

@ -0,0 +1,38 @@
import { renderWithProviders, connectedState, makeUser } from '../../__test-utils__';
import { Data } from '@app/types';
import ModGuard from './ModGuard';
vi.mock('@app/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/hooks')>();
return { ...actual, useWebClient: vi.fn(() => ({})) };
});
describe('ModGuard', () => {
it('redirects when user is not a moderator', () => {
const { container } = renderWithProviders(<ModGuard />, {
preloadedState: connectedState,
route: '/logs',
});
expect(container.textContent).toBe('');
});
it('renders nothing visible when user is a moderator', () => {
const modUser = makeUser({
userLevel: Data.ServerInfo_User_UserLevelFlag.IsModerator,
});
const { container } = renderWithProviders(<ModGuard />, {
preloadedState: {
...connectedState,
server: {
...(connectedState.server as any),
user: modUser,
},
},
route: '/logs',
});
expect(container.textContent).toBe('');
});
});

View file

@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import InputField from './InputField';
describe('InputField', () => {
const defaultProps = {
input: { name: 'test', value: '', onChange: vi.fn(), onBlur: vi.fn(), onFocus: vi.fn() },
meta: { touched: false, error: null, warning: null },
label: 'Test Field',
};
it('renders a text field with label', () => {
render(<InputField {...defaultProps} />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('shows error when touched and has error', () => {
render(<InputField {...defaultProps} meta={{ touched: true, error: 'Required', warning: null }} />);
expect(screen.getByText('Required')).toBeInTheDocument();
});
it('shows warning when touched and has warning', () => {
render(<InputField {...defaultProps} meta={{ touched: true, error: null, warning: 'Weak password' }} />);
expect(screen.getByText('Weak password')).toBeInTheDocument();
});
it('does not show validation messages when not touched', () => {
render(<InputField {...defaultProps} meta={{ touched: false, error: 'Required', warning: null }} />);
expect(screen.queryByText('Required')).not.toBeInTheDocument();
});
});

View file

@ -16,7 +16,7 @@ import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import { useWebClient } from '@app/hooks';
import { KnownHostDialog } from '@app/dialogs';
import { useReduxEffect } from '@app/hooks';
import { HostDTO } from '@app/services';
import { DefaultHosts, HostDTO, getHostPort } from '@app/services';
import { ServerTypes } from '@app/store';
import { App } from '@app/types';
import Toast from '../Toast/Toast';
@ -87,7 +87,7 @@ const KnownHosts = (props) => {
if (!hosts?.length) {
// @TODO: find a better pattern to seeding default data in indexedDB
await HostDTO.bulkAdd(App.DefaultHosts);
await HostDTO.bulkAdd(DefaultHosts);
loadKnownHosts();
} else {
const selectedHost = hosts.find(({ lastSelected }) => lastSelected) || hosts[0];
@ -197,7 +197,7 @@ const KnownHosts = (props) => {
const testConnection = () => {
setTestingConnection(TestConnection.TESTING);
const options = { ...App.getHostPort(hostsState.selectedHost) };
const options = { ...getHostPort(hostsState.selectedHost) };
webClient.request.authentication.testConnection(options);
}
@ -238,7 +238,7 @@ const KnownHosts = (props) => {
{
hostsState.hosts.map((host, index) => {
const hostPort = App.getHostPort(hostsState.hosts[index]);
const hostPort = getHostPort(hostsState.hosts[index]);
return (
<MenuItem value={host} key={index}>

View file

@ -0,0 +1,19 @@
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../__test-utils__';
import Message from './Message';
describe('Message', () => {
it('renders a plain message', () => {
const message = { message: 'Hello world' };
renderWithProviders(<Message message={message} />);
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it('renders the message container', () => {
const message = { message: 'Test message' };
const { container } = renderWithProviders(<Message message={message} />);
expect(container.querySelector('.message')).toBeInTheDocument();
});
});

View file

@ -3,8 +3,7 @@ import Grid from '@mui/material/Grid';
import './ThreePaneLayout.css';
// @DEPRECATED
// This component sucks balls, dont use it. It will be removed sooner than later.
/** @deprecated Scheduled for replacement with a more flexible layout component. */
function ThreePaneLayout(props: ThreePaneLayoutProps) {
return (
<div className="three-pane-layout">

View file

@ -0,0 +1,46 @@
import { screen } from '@testing-library/react';
import { renderWithProviders, connectedState, makeUser, createMockWebClient } from '../../__test-utils__';
import UserDisplay from './UserDisplay';
const mockWebClient = createMockWebClient();
vi.mock('@app/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/hooks')>();
return { ...actual, useWebClient: vi.fn(() => mockWebClient) };
});
vi.mock('@app/images', () => ({
Images: { Countries: { us: 'us.png', de: 'de.png' } },
}));
describe('UserDisplay', () => {
it('renders user name', () => {
const user = makeUser({ name: 'TestPlayer', country: 'us' });
renderWithProviders(<UserDisplay user={user} />, {
preloadedState: connectedState,
});
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
});
it('renders country flag image', () => {
const user = makeUser({ name: 'TestPlayer', country: 'us' });
renderWithProviders(<UserDisplay user={user} />, {
preloadedState: connectedState,
});
const img = screen.getByAltText('us');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'us.png');
});
it('renders link to player profile', () => {
const user = makeUser({ name: 'TestPlayer', country: 'us' });
renderWithProviders(<UserDisplay user={user} />, {
preloadedState: connectedState,
});
const link = screen.getByRole('link', { name: /TestPlayer/ });
expect(link).toHaveAttribute('href', '/player/TestPlayer');
});
});

View file

@ -0,0 +1,21 @@
import { render } from '@testing-library/react';
import VirtualList from './VirtualList';
describe('VirtualList', () => {
it('renders without crashing with empty items', () => {
const { container } = render(<VirtualList items={[]} />);
expect(container.querySelector('.virtual-list')).toBeInTheDocument();
});
it('accepts className as a string', () => {
const { container } = render(<VirtualList items={[]} className="custom-class" />);
expect(container.querySelector('.custom-class')).toBeInTheDocument();
});
it('applies empty string as default className (not object)', () => {
const { container } = render(<VirtualList items={[]} />);
const list = container.querySelector('.virtual-list__list');
// className should not contain "[object Object]"
expect(list?.className).not.toContain('[object Object]');
});
});

View file

@ -15,7 +15,7 @@ const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
</div>
);
const VirtualList = ({ items, className = {}, size = 30 }) => (
const VirtualList = ({ items, className = '', size = 30 }) => (
<div className="virtual-list">
<List<RowData>
className={`virtual-list__list ${className}`}

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
@ -25,7 +24,21 @@ const Account = () => {
const user = useAppSelector(state => ServerSelectors.getUser(state));
const webClient = useWebClient();
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {};
let url = URL.createObjectURL(new Blob([avatarBmp as BlobPart], { 'type': 'image/png' }));
const avatarUrl = useMemo(() => {
if (!avatarBmp) {
return '';
}
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
}, [avatarBmp]);
useEffect(() => {
return () => {
if (avatarUrl) {
URL.revokeObjectURL(avatarUrl);
}
};
}, [avatarUrl]);
const { t } = useTranslation();
@ -42,41 +55,41 @@ const Account = () => {
<AuthGuard />
<div className="account-column">
<Paper className="account-list">
<div className="">
<div>
Buddies Online: ?/{buddyList.length}
</div>
<VirtualList
items={ buddyList.map(user => (
<ListItemButton dense>
<ListItemButton key={user.name} dense>
<UserDisplay user={user} />
</ListItemButton>
)) }
/>
<div className="" style={{ borderTop: '1px solid' }}>
<div style={{ borderTop: '1px solid' }}>
<AddToBuddies onSubmit={handleAddToBuddies} />
</div>
</Paper>
</div>
<div className="account-column">
<Paper className="account-list overflow-scroll">
<div className="">
<div>
Ignored Users Online: ?/{ignoreList.length}
</div>
<VirtualList
items={ ignoreList.map(user => (
<ListItemButton dense>
<ListItemButton key={user.name} dense>
<UserDisplay user={user} />
</ListItemButton>
)) }
/>
<div className="" style={{ borderTop: '1px solid' }}>
<div style={{ borderTop: '1px solid' }}>
<AddToIgnore onSubmit={handleAddToIgnore} />
</div>
</Paper>
</div>
<div className="account-column overflow-scroll">
<Paper className="account-details" style={{ margin: '0 0 5px 0' }}>
<img src={url} alt={name} />
{ avatarUrl && <img src={avatarUrl} alt={name} /> }
<p><strong>{name}</strong></p>
<p>Location: ({country?.toUpperCase()})</p>
<p>User Level: {userLevel}</p>
@ -95,7 +108,7 @@ const Account = () => {
<Button
color="primary"
variant="contained"
onClick={() => request.authentication.disconnect()}
onClick={() => webClient.request.authentication.disconnect()}
>
{ t('Common.disconnect') }
</Button>

View file

@ -11,7 +11,7 @@ import { LanguageDropdown } from '@app/components';
import { LoginForm } from '@app/forms';
import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks';
import { Images } from '@app/images';
import { HostDTO, serverProps } from '@app/services';
import { HostDTO, getHostPort, serverProps } from '@app/services';
import { App, Enriched } from '@app/types';
import { ServerSelectors, ServerTypes } from '@app/store';
import Layout from '../Layout/Layout';
@ -125,7 +125,7 @@ const Login = () => {
const { userName, password, selectedHost, remember } = loginForm;
const options: Omit<Enriched.LoginConnectOptions, 'reason'> = {
...App.getHostPort(selectedHost),
...getHostPort(selectedHost),
userName,
password,
};
@ -154,7 +154,7 @@ const Login = () => {
const { userName, password, email, country, realName, selectedHost } = registerForm;
webClient.request.authentication.register({
...App.getHostPort(selectedHost),
...getHostPort(selectedHost),
userName,
password,
email,
@ -177,7 +177,7 @@ const Login = () => {
const handleRequestPasswordResetDialogSubmit = (form) => {
const { userName, email, selectedHost } = form;
const { host, port } = App.getHostPort(selectedHost);
const { host, port } = getHostPort(selectedHost);
if (email) {
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
@ -188,7 +188,7 @@ const Login = () => {
};
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
const { host, port } = App.getHostPort(selectedHost);
const { host, port } = getHostPort(selectedHost);
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
};

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line
import React, { useEffect } from "react";
import { useEffect } from 'react';
import { AuthGuard, ModGuard } from '@app/components';
import { SearchForm } from '@app/forms';

View file

@ -0,0 +1,35 @@
import { screen } from '@testing-library/react';
import { renderWithProviders, connectedWithRoomsState } from '../../__test-utils__';
import OpenGames from './OpenGames';
vi.mock('@app/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/hooks')>();
return { ...actual, useWebClient: vi.fn(() => ({})) };
});
describe('OpenGames', () => {
const roomWithGames = {
info: { roomId: 1, name: 'Main Room' },
};
it('renders the games table headers', () => {
renderWithProviders(<OpenGames room={roomWithGames} />, {
preloadedState: connectedWithRoomsState,
});
expect(screen.getByText('Age')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Creator')).toBeInTheDocument();
expect(screen.getByText('Type')).toBeInTheDocument();
expect(screen.getByText('Players')).toBeInTheDocument();
expect(screen.getByText('Spectators')).toBeInTheDocument();
});
it('renders without crashing when no games exist', () => {
const { container } = renderWithProviders(<OpenGames room={roomWithGames} />, {
preloadedState: connectedWithRoomsState,
});
expect(container.querySelector('.games')).toBeInTheDocument();
});
});

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line
import React from "react";
import React from 'react';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@ -42,17 +41,17 @@ const OpenGames = ({ room }: OpenGamesProps) => {
RoomsDispatch.sortGames(roomId, field, order);
};
const isUnavailableGame = ({ started, maxPlayers, playerCount }) =>
const isAvailable = ({ started, maxPlayers, playerCount }) =>
!started && playerCount < maxPlayers;
const isPasswordProtectedGame = ({ withPassword }) => !withPassword;
const isOpen = ({ withPassword }) => !withPassword;
const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies;
const isPublic = ({ onlyBuddies }) => !onlyBuddies;
const games = sortedGames.filter(game => (
isUnavailableGame(game.info) &&
isPasswordProtectedGame(game.info) &&
isBuddiesOnlyGame(game.info)
isAvailable(game.info) &&
isOpen(game.info) &&
isPublic(game.info)
));
return (

View file

@ -25,7 +25,7 @@ const Room = () => {
const navigate = useNavigate();
const params = useParams();
const roomId = parseInt(params.roomId, 0);
const roomId = parseInt(params.roomId, 10);
const room = rooms[roomId];
const roomMessages = messages[roomId];
const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId));
@ -37,6 +37,10 @@ const Room = () => {
}
}, [joined]);
if (!room) {
return null;
}
const handleRoomSay = ({ message }) => {
if (message) {
webClient.request.rooms.roomSay(roomId, message);
@ -78,7 +82,7 @@ const Room = () => {
<VirtualList
className="room-view__side-list"
items={ users.map(user => (
<ListItemButton className="room-view__side-list__item">
<ListItemButton key={user.name} className="room-view__side-list__item">
<UserDisplay user={user} />
</ListItemButton>
)) }

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line
import React from "react";
import React from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import ListItemButton from '@mui/material/ListItemButton';
@ -40,6 +39,7 @@ const Server = () => {
bottom={(
<Paper className="serverMessage overflow-scroll">
{/* message is sanitized via DOMPurify in websocket/events/session/serverMessage.ts */}
<div className="serverMessage__content" dangerouslySetInnerHTML={{ __html: message }} />
</Paper>
)}
@ -51,7 +51,7 @@ const Server = () => {
</div>
<VirtualList
items={ users.map(user => (
<ListItemButton dense>
<ListItemButton key={user.name} dense>
<UserDisplay user={user} />
</ListItemButton>
)) }

View file

@ -43,8 +43,6 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm
const handleOnSubmit = ({ userName, ...values }) => {
userName = userName?.trim();
console.log(userName, values);
onSubmit({ userName, ...values });
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Form, Field } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
@ -99,10 +99,11 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
{({ handleSubmit, form }) => {
if (emailRequired) {
// Allow form render to complete
setTimeout(() => form.mutators.setFieldTouched('email', true))
}
useEffect(() => {
if (emailRequired) {
form.mutators.setFieldTouched('email', true);
}
}, [emailRequired]);
return (
<>

View file

@ -1,6 +1,5 @@
export * from './useAutoConnect';
export * from './useFireOnce';
export * from './useDebounce';
export * from './useLocaleSort';
export * from './useReduxEffect';
export * from './useWebClient';

View file

@ -0,0 +1,86 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useAutoConnect } from './useAutoConnect';
const mockSave = vi.fn();
let storedSetting: any = null;
vi.mock('@app/services', () => ({
SettingDTO: class MockSettingDTO {
user: string;
autoConnect = false;
constructor(user: string) {
this.user = user;
}
save = mockSave;
static get = vi.fn(() => Promise.resolve(storedSetting));
},
}));
vi.mock('@app/types', () => ({
App: { APP_USER: '*app' },
}));
describe('useAutoConnect', () => {
beforeEach(() => {
storedSetting = null;
mockSave.mockClear();
});
test('returns undefined initially, then resolves to stored autoConnect value', async () => {
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
const { result } = renderHook(() => useAutoConnect());
expect(result.current[0]).toBeUndefined();
await waitFor(() => {
expect(result.current[0]).toBe(true);
});
});
test('creates and saves a new SettingDTO when none exists', async () => {
storedSetting = null;
const { result } = renderHook(() => useAutoConnect());
await waitFor(() => {
expect(result.current[0]).toBe(false);
});
// save() called once for the newly created setting
expect(mockSave).toHaveBeenCalledTimes(1);
});
test('persists when setAutoConnect is called', async () => {
storedSetting = { user: '*app', autoConnect: false, save: mockSave };
const { result } = renderHook(() => useAutoConnect());
await waitFor(() => {
expect(result.current[0]).toBe(false);
});
mockSave.mockClear();
act(() => {
result.current[1](true);
});
await waitFor(() => {
expect(result.current[0]).toBe(true);
});
expect(mockSave).toHaveBeenCalled();
});
test('does not save redundantly on initial mount when setting exists', async () => {
storedSetting = { user: '*app', autoConnect: true, save: mockSave };
renderHook(() => useAutoConnect());
await waitFor(() => {
expect(mockSave).not.toHaveBeenCalled();
});
});
});

View file

@ -1,35 +1,33 @@
import { useEffect, useState } from 'react';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { SettingDTO } from '@app/services';
import { App } from '@app/types';
export function useAutoConnect() {
const [setting, setSetting] = useState(undefined);
const [autoConnect, setAutoConnect] = useState(undefined);
export function useAutoConnect(): [boolean | undefined, Dispatch<SetStateAction<boolean | undefined>>] {
const [setting, setSetting] = useState<SettingDTO | undefined>(undefined);
const [autoConnect, setAutoConnect] = useState<boolean | undefined>(undefined);
const prevAutoConnectRef = useRef<boolean | undefined>(undefined);
useEffect(() => {
SettingDTO.get(App.APP_USER).then((setting: SettingDTO) => {
if (!setting) {
setting = new SettingDTO(App.APP_USER);
setting.save();
SettingDTO.get(App.APP_USER).then((loaded: SettingDTO) => {
if (!loaded) {
loaded = new SettingDTO(App.APP_USER);
loaded.save();
}
setSetting(setting);
setSetting(loaded);
setAutoConnect(loaded.autoConnect);
prevAutoConnectRef.current = loaded.autoConnect;
});
}, []);
useEffect(() => {
if (setting) {
setAutoConnect(setting.autoConnect);
}
}, [setting]);
useEffect(() => {
if (setting) {
if (setting && autoConnect !== prevAutoConnectRef.current) {
prevAutoConnectRef.current = autoConnect;
setting.autoConnect = autoConnect;
setting.save();
}
}, [setting, autoConnect]);
}, [autoConnect]);
return [autoConnect, setAutoConnect];
}

View file

@ -1,34 +0,0 @@
import { useCallback } from 'react';
type UseDebounceType = (...args: any) => any;
const DEBOUNCE_DELAY = 250;
export interface DebouncedFn<T extends UseDebounceType> {
(...args: Parameters<T>): void;
cancel(): void;
}
function debounce<T extends UseDebounceType>(fn: T, timeout: number): DebouncedFn<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const debounced = ((...args: Parameters<T>): void => {
if (timer !== undefined) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...args), timeout);
}) as DebouncedFn<T>;
debounced.cancel = (): void => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
return debounced;
}
export function useDebounce<T extends UseDebounceType>(
fn: T,
deps: any[] = [],
timeout: number = DEBOUNCE_DELAY
): DebouncedFn<T> {
return useCallback(debounce(fn, timeout), deps);
}

View file

@ -2,6 +2,8 @@ import {
render,
fireEvent,
waitFor,
renderHook,
act,
} from '@testing-library/react';
import { useFireOnce } from './useFireOnce';
@ -98,4 +100,56 @@ describe('useFireOnce hook', () => {
{ timeout: 100 }
);
});
test('resetInFlightStatus re-enables firing', () => {
const fn = vi.fn();
const { result } = renderHook(() => useFireOnce(fn));
act(() => {
result.current[2]();
});
expect(result.current[0]).toBe(true);
expect(fn).toHaveBeenCalledTimes(1);
act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(false);
act(() => {
result.current[2]();
});
expect(fn).toHaveBeenCalledTimes(2);
});
test('calls the latest fn when parent updates it', () => {
const fn1 = vi.fn();
const fn2 = vi.fn();
const { result, rerender } = renderHook(({ fn }) => useFireOnce(fn), {
initialProps: { fn: fn1 },
});
rerender({ fn: fn2 });
act(() => {
result.current[2]();
});
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledTimes(1);
});
test('passes all arguments through to fn', () => {
const fn = vi.fn();
const { result } = renderHook(() => useFireOnce(fn));
act(() => {
result.current[2]('a', 'b', 'c');
});
expect(fn).toHaveBeenCalledWith('a', 'b', 'c');
});
});

View file

@ -1,15 +1,20 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
type UseFireOnceType = (...args: any) => any;
export function useFireOnce<T extends UseFireOnceType>(fn: T): [boolean, any, any] {
const [actionIsInFlight, setActionIsInFlight] = useState(false)
const handleFireOnce = useCallback((args) => {
export function useFireOnce<T extends UseFireOnceType>(fn: T): [boolean, () => void, (...args: Parameters<T>) => void] {
const [actionIsInFlight, setActionIsInFlight] = useState(false);
const fnRef = useRef(fn);
fnRef.current = fn;
const handleFireOnce = useCallback((...args: Parameters<T>) => {
setActionIsInFlight(true);
fn(args);
}, [])
function resetInFlightStatus() {
fnRef.current(...args);
}, []);
const resetInFlightStatus = useCallback(() => {
setActionIsInFlight(false);
}
return [actionIsInFlight, resetInFlightStatus, handleFireOnce]
}, []);
return [actionIsInFlight, resetInFlightStatus, handleFireOnce];
}

View file

@ -0,0 +1,80 @@
import { renderHook } from '@testing-library/react';
import { useLocaleSort } from './useLocaleSort';
let mockLanguage = 'en';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
get language() {
return mockLanguage;
},
},
}),
}));
describe('useLocaleSort', () => {
beforeEach(() => {
mockLanguage = 'en';
});
test('sorts strings by locale using the valueGetter', () => {
const arr = ['c', 'a', 'b'];
const { result } = renderHook(() => useLocaleSort(arr, (v) => v));
expect(result.current).toEqual(['a', 'b', 'c']);
});
test('sorts using valueGetter to resolve display values', () => {
const lookup: Record<string, string> = { x: 'cherry', y: 'apple', z: 'banana' };
const arr = ['x', 'y', 'z'];
const { result } = renderHook(() => useLocaleSort(arr, (v) => lookup[v]));
expect(result.current).toEqual(['y', 'z', 'x']);
});
test('handles empty array', () => {
const { result } = renderHook(() => useLocaleSort([], (v) => v));
expect(result.current).toEqual([]);
});
test('does not mutate the input array', () => {
const arr = ['c', 'a', 'b'];
renderHook(() => useLocaleSort(arr, (v) => v));
expect(arr).toEqual(['c', 'a', 'b']);
});
test('updates when arr prop changes', () => {
let arr = ['c', 'a'];
const getter = (v: string) => v;
const { result, rerender } = renderHook(() => useLocaleSort(arr, getter));
expect(result.current).toEqual(['a', 'c']);
arr = ['z', 'b', 'a'];
rerender();
expect(result.current).toEqual(['a', 'b', 'z']);
});
test('re-sorts when language changes', () => {
// Swedish sorts ä after z; English sorts ä near a
const arr = ['ä', 'b', 'z'];
const getter = (v: string) => v;
mockLanguage = 'en';
const { result, rerender } = renderHook(() => useLocaleSort(arr, getter));
const englishOrder = [...result.current];
mockLanguage = 'sv';
rerender();
const swedishOrder = [...result.current];
// In Swedish, ä comes after z
expect(swedishOrder[swedishOrder.length - 1]).toBe('ä');
// In English, ä sorts near 'a', before 'b'
expect(englishOrder.indexOf('ä')).toBeLessThan(englishOrder.indexOf('b'));
});
});

View file

@ -1,18 +1,11 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export function useLocaleSort(arr: string[], valueGetter: (value: string) => string) {
const [state] = useState<string[]>(arr);
const [sorted, setSorted] = useState<string[]>([]);
export function useLocaleSort(arr: string[], valueGetter: (value: string) => string): string[] {
const { i18n } = useTranslation();
useEffect(() => {
return useMemo(() => {
const collator = new Intl.Collator(i18n.language);
const sorter = (a, b) => collator.compare(valueGetter(a), valueGetter(b));
setSorted(state.sort(sorter));
}, [state, i18n.language]);
return sorted;
return [...arr].sort((a, b) => collator.compare(valueGetter(a), valueGetter(b)));
}, [arr, i18n.language, valueGetter]);
}

View file

@ -0,0 +1,138 @@
import { renderHook, act } from '@testing-library/react';
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { StrictMode, ReactNode } from 'react';
import { useReduxEffect } from './useReduxEffect';
import { actionReducer } from '../store/actions/actionReducer';
function makeStore() {
return configureStore({
reducer: combineReducers({ action: actionReducer }),
});
}
function makeWrapper(store: ReturnType<typeof makeStore>) {
return function Wrapper({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
};
}
describe('useReduxEffect', () => {
test('fires callback when matching action type is dispatched', () => {
const store = makeStore();
const effect = vi.fn();
renderHook(() => useReduxEffect(effect, 'TEST_ACTION'), {
wrapper: makeWrapper(store),
});
act(() => {
store.dispatch({ type: 'TEST_ACTION', payload: 'hello' });
});
expect(effect).toHaveBeenCalledTimes(1);
expect(effect).toHaveBeenCalledWith(
expect.objectContaining({ type: 'TEST_ACTION' }),
);
});
test('does not fire for non-matching action types', () => {
const store = makeStore();
const effect = vi.fn();
renderHook(() => useReduxEffect(effect, 'LISTEN_FOR'), {
wrapper: makeWrapper(store),
});
act(() => {
store.dispatch({ type: 'OTHER_ACTION' });
});
expect(effect).not.toHaveBeenCalled();
});
test('handles array of action types', () => {
const store = makeStore();
const effect = vi.fn();
renderHook(() => useReduxEffect(effect, ['TYPE_A', 'TYPE_B']), {
wrapper: makeWrapper(store),
});
act(() => {
store.dispatch({ type: 'TYPE_A' });
});
act(() => {
store.dispatch({ type: 'TYPE_B' });
});
act(() => {
store.dispatch({ type: 'TYPE_C' });
});
expect(effect).toHaveBeenCalledTimes(2);
});
test('does not double-fire in StrictMode', () => {
const store = makeStore();
const effect = vi.fn();
function StrictWrapper({ children }: { children: ReactNode }) {
return (
<StrictMode>
<Provider store={store}>{children}</Provider>
</StrictMode>
);
}
renderHook(() => useReduxEffect(effect, 'TEST'), {
wrapper: StrictWrapper,
});
act(() => {
store.dispatch({ type: 'TEST' });
});
expect(effect).toHaveBeenCalledTimes(1);
});
test('catches action dispatched before mount via sync check', () => {
const store = makeStore();
const effect = vi.fn();
// Dispatch before the hook mounts
store.dispatch({ type: 'EARLY_ACTION' });
renderHook(() => useReduxEffect(effect, 'EARLY_ACTION'), {
wrapper: makeWrapper(store),
});
expect(effect).toHaveBeenCalledTimes(1);
expect(effect).toHaveBeenCalledWith(
expect.objectContaining({ type: 'EARLY_ACTION' }),
);
});
test('uses latest effect callback via ref', () => {
const store = makeStore();
const effect1 = vi.fn();
const effect2 = vi.fn();
const { rerender } = renderHook(
({ cb }) => useReduxEffect(cb, 'TEST'),
{
wrapper: makeWrapper(store),
initialProps: { cb: effect1 },
},
);
rerender({ cb: effect2 });
act(() => {
store.dispatch({ type: 'TEST' });
});
expect(effect1).not.toHaveBeenCalled();
expect(effect2).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,48 @@
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { WebClientProvider, useWebClient } from './useWebClient';
vi.mock('@app/websocket', () => ({
WebClient: class MockWebClient {},
}));
vi.mock('@app/api', () => ({
createWebClientRequest: vi.fn(() => 'request'),
createWebClientResponse: vi.fn(() => 'response'),
}));
function Wrapper({ children }: { children: ReactNode }) {
return <WebClientProvider>{children}</WebClientProvider>;
}
describe('useWebClient', () => {
test('provides the WebClient instance to children', () => {
const { result } = renderHook(() => useWebClient(), { wrapper: Wrapper });
expect(result.current).toBeDefined();
expect(result.current.constructor.name).toBe('MockWebClient');
});
test('throws when used outside WebClientProvider', () => {
// Suppress React error boundary console output
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useWebClient());
}).toThrow('useWebClient must be used within a WebClientProvider');
spy.mockRestore();
});
test('returns the same instance across re-renders', () => {
const { result, rerender } = renderHook(() => useWebClient(), {
wrapper: Wrapper,
});
const first = result.current;
rerender();
const second = result.current;
expect(first).toBe(second);
});
});

View file

@ -0,0 +1,261 @@
import { cardImporterService } from './CardImporterService';
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function jsonResponse(body: unknown, contentType = 'application/json') {
return {
ok: true,
headers: new Headers({ 'Content-Type': contentType }),
json: () => Promise.resolve(body),
} as unknown as Response;
}
function textResponse(body: string, ok = true) {
return {
ok,
headers: new Headers({ 'Content-Type': 'application/xml' }),
text: () => Promise.resolve(body),
} as unknown as Response;
}
function failedResponse(status = 500) {
return {
ok: false,
status,
headers: new Headers({ 'Content-Type': 'application/json' }),
json: () => Promise.resolve({}),
text: () => Promise.resolve(''),
} as unknown as Response;
}
// Minimal MTGJSON-shaped fixture
const mtgjsonFixture = {
data: {
SET_B: {
code: 'SET_B',
name: 'Set B',
releaseDate: '2020-06-01',
cards: [
{ name: 'Zebra' },
{ name: 'Alpha' },
],
tokens: [{ name: 'Token B' }],
},
SET_A: {
code: 'SET_A',
name: 'Set A',
releaseDate: '2019-01-01',
cards: [
{ name: 'Alpha' },
{ name: 'Beta' },
],
tokens: [{ name: 'Token A' }],
},
},
};
describe('CardImporterService', () => {
describe('importCards', () => {
it('fetches and parses valid MTGJSON data into sorted cards and sets', async () => {
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
const { cards, sets } = await cardImporterService.importCards('http://example.com/cards.json');
expect(cards).toHaveLength(3);
expect(cards[0].name).toBe('Alpha');
expect(cards[1].name).toBe('Beta');
expect(cards[2].name).toBe('Zebra');
expect(sets).toHaveLength(2);
expect(sets[0].name).toBe('Set A');
expect(sets[1].name).toBe('Set B');
});
it('sorts sets by releaseDate ascending', async () => {
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
const { sets } = await cardImporterService.importCards('http://example.com/cards.json');
expect(sets[0].code).toBe('SET_A');
expect(sets[1].code).toBe('SET_B');
});
it('deduplicates cards by name, keeping last occurrence (later set wins)', async () => {
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
const { cards } = await cardImporterService.importCards('http://example.com/cards.json');
// Alpha appears in both SET_A and SET_B; SET_B is later so its version overwrites
// 3 unique names: Alpha (deduped), Beta (SET_A only), Zebra (SET_B only)
expect(cards).toHaveLength(3);
expect(cards.map(c => c.name)).toEqual(['Alpha', 'Beta', 'Zebra']);
});
it('maps set cards and tokens to name arrays', async () => {
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture));
const { sets } = await cardImporterService.importCards('http://example.com/cards.json');
expect(sets[0].cards).toEqual(['Alpha', 'Beta']);
expect(sets[0].tokens).toEqual(['Token A']);
});
it('rejects when response is not ok', async () => {
mockFetch.mockResolvedValue(failedResponse(404));
await expect(cardImporterService.importCards('http://example.com/cards.json'))
.rejects.toThrow('Card import must be in valid MTG JSON format');
});
it('rejects when Content-Type does not contain application/json', async () => {
mockFetch.mockResolvedValue({
ok: true,
headers: new Headers({ 'Content-Type': 'text/html' }),
json: () => Promise.resolve({}),
} as unknown as Response);
await expect(cardImporterService.importCards('http://example.com/cards.json'))
.rejects.toThrow('Card import must be in valid MTG JSON format');
});
it('accepts Content-Type with charset parameter', async () => {
mockFetch.mockResolvedValue(jsonResponse(mtgjsonFixture, 'application/json; charset=utf-8'));
const { cards } = await cardImporterService.importCards('http://example.com/cards.json');
expect(cards.length).toBeGreaterThan(0);
});
it('rejects when JSON structure is invalid (missing data key)', async () => {
mockFetch.mockResolvedValue(jsonResponse({ notData: {} }));
await expect(cardImporterService.importCards('http://example.com/cards.json'))
.rejects.toThrow('Card import must be in valid MTG JSON format');
});
it('preserves the original error as cause', async () => {
mockFetch.mockResolvedValue(jsonResponse({ notData: {} }));
try {
await cardImporterService.importCards('http://example.com/cards.json');
expect.fail('should have thrown');
} catch (err) {
expect((err as Error).cause).toBeDefined();
}
});
});
describe('importTokens', () => {
const validXml = `<?xml version="1.0" encoding="UTF-8"?>
<cockatrice_tokens>
<card>
<name value="Soldier" />
<set value="M21" picURL="http://example.com/soldier.png" />
<tablerow value="1" />
</card>
</cockatrice_tokens>`;
it('fetches and parses valid XML into token objects', async () => {
mockFetch.mockResolvedValue(textResponse(validXml));
const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml');
expect(tokens).toHaveLength(1);
expect(tokens[0]).toHaveProperty('name');
});
it('parses token attributes correctly', async () => {
mockFetch.mockResolvedValue(textResponse(validXml));
const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml');
const token = tokens[0] as Record<string, any>;
expect(token.name.value).toBe('Soldier');
expect(token.set.value).toBe('M21');
expect(token.set.picURL).toBe('http://example.com/soldier.png');
});
it('rejects when response is not ok', async () => {
mockFetch.mockResolvedValue({ ok: false, text: () => Promise.resolve('') } as unknown as Response);
await expect(cardImporterService.importTokens('http://example.com/tokens.xml'))
.rejects.toThrow('Failed to fetch');
});
it('rejects when XML is malformed', async () => {
mockFetch.mockResolvedValue(textResponse('<not-valid-xml>'));
await expect(cardImporterService.importTokens('http://example.com/tokens.xml'))
.rejects.toThrow('Token import must be in valid MTG XML format');
});
it('returns empty array when XML has no card elements', async () => {
const emptyXml = '<?xml version="1.0"?><cockatrice_tokens></cockatrice_tokens>';
mockFetch.mockResolvedValue(textResponse(emptyXml));
const tokens = await cardImporterService.importTokens('http://example.com/tokens.xml');
expect(tokens).toEqual([]);
});
it('preserves the original error as cause on parse failure', async () => {
mockFetch.mockResolvedValue(textResponse('<not-valid-xml>'));
try {
await cardImporterService.importTokens('http://example.com/tokens.xml');
expect.fail('should have thrown');
} catch (err) {
expect((err as Error).cause).toBeDefined();
}
});
});
describe('parseXmlAttributes', () => {
function parseXml(xml: string) {
const dom = new DOMParser().parseFromString(xml, 'application/xml');
return (cardImporterService as any).parseXmlAttributes(dom.documentElement);
}
it('parses simple child elements into key-value pairs', () => {
const result = parseXml('<card><name value="Soldier" /></card>');
expect(result.name.value).toBe('Soldier');
});
it('parses nested elements recursively', () => {
const result = parseXml('<card><prop><cmc value="2" /></prop></card>');
expect(result.prop.value).toHaveProperty('cmc');
expect(result.prop.value.cmc.value).toBe('2');
});
it('includes XML attributes alongside value', () => {
const result = parseXml('<card><set value="M21" picURL="http://img.png" /></card>');
expect(result.set.value).toBe('M21');
expect(result.set.picURL).toBe('http://img.png');
});
it('converts duplicate tag names into an array preserving all values', () => {
const result = parseXml(
'<card><related value="Token A" /><related value="Token B" /></card>'
);
expect(Array.isArray(result.related)).toBe(true);
expect(result.related).toHaveLength(2);
expect(result.related[0].value).toBe('Token A');
expect(result.related[1].value).toBe('Token B');
});
it('appends to existing array for 3+ duplicate tag names', () => {
const result = parseXml(
'<card><set value="A" /><set value="B" /><set value="C" /></card>'
);
expect(Array.isArray(result.set)).toBe(true);
expect(result.set).toHaveLength(3);
expect(result.set[0].value).toBe('A');
expect(result.set[1].value).toBe('B');
expect(result.set[2].value).toBe('C');
});
it('reads innerHTML as value for leaf elements without children', () => {
const result = parseXml('<card><text>Some card text</text></card>');
expect(result.text.value).toBe('Some card text');
});
});
});

View file

@ -1,12 +1,19 @@
// Fetch and parse card sets
import { App } from '@app/types';
class CardImporterService {
importCards(url): Promise<any> {
importCards(url: string): Promise<{ cards: App.Card[]; sets: App.Set[] }> {
const error = 'Card import must be in valid MTG JSON format';
return fetch(url)
.then(response => {
if (response.headers.get('Content-Type') !== 'application/json') {
if (!response.ok) {
throw new Error(error);
}
const contentType = response.headers.get('Content-Type') ?? '';
if (!contentType.includes('application/json')) {
throw new Error(error);
}
@ -34,13 +41,13 @@ class CardImporterService {
.map(key => unsortedCards[key]);
return { cards, sets };
} catch {
throw new Error(error);
} catch (err) {
throw new Error(error, { cause: err });
}
});
}
importTokens(url): Promise<any> {
importTokens(url: string): Promise<Record<string, unknown>[]> {
const error = 'Token import must be in valid MTG XML format';
return fetch(url)
@ -56,13 +63,17 @@ class CardImporterService {
const parser = new DOMParser();
const dom = parser.parseFromString(xmlString, 'application/xml');
if (dom.querySelector('parsererror')) {
throw new Error(error);
}
const tokens = Array.from(dom.querySelectorAll('card')).map(
(tokenElement) => this.parseXmlAttributes(tokenElement)
);
return tokens;
} catch {
throw new Error(error);
} catch (err) {
throw new Error(error, { cause: err });
}
})
}
@ -90,7 +101,7 @@ class CardImporterService {
if (Array.isArray(attributes[child.tagName])) {
attributes[child.tagName].push(parsedAttributes)
} else {
attributes[child.tagName] = [parsedAttributes];
attributes[child.tagName] = [attributes[child.tagName], parsedAttributes];
}
} else {
attributes[child.tagName] = parsedAttributes;

View file

@ -0,0 +1,47 @@
import { App } from '@app/types';
export const DefaultHosts: App.Host[] = [
{
name: 'Chickatrice',
host: 'mtg.chickatrice.net',
port: '443',
localPort: '4748',
editable: false,
},
{
name: 'Rooster',
host: 'server.cockatrice.us/servatrice',
port: '4748',
localHost: 'server.cockatrice.us',
editable: false,
},
{
name: 'Rooster Beta',
host: 'beta.cockatrice.us/servatrice',
port: '4748',
localHost: 'beta.cockatrice.us',
editable: false,
},
{
name: 'Tetrarch',
host: 'mtg.tetrarch.co/servatrice',
port: '443',
editable: false,
},
];
export const getHostPort = (host: App.Host): { host: string, port: string } => {
const isLocal = window.location.hostname === 'localhost';
if (!host) {
return {
host: '',
port: ''
};
}
return {
host: !isLocal ? host.host : host.localHost || host.host,
port: !isLocal ? host.port : host.localPort || host.port,
};
};

View file

@ -1,3 +1,4 @@
import { IndexableType } from 'dexie';
import { App } from '@app/types';
import { dexieService } from '../DexieService';
@ -7,11 +8,11 @@ export class CardDTO extends App.Card {
return dexieService.cards.put(this);
}
static get(name) {
static get(name: string) {
return dexieService.cards.where('name').equalsIgnoreCase(name).first();
}
static bulkAdd(cards: CardDTO[]): Promise<any> {
static bulkAdd(cards: CardDTO[]): Promise<IndexableType> {
return dexieService.cards.bulkPut(cards);
}
};

View file

@ -1,3 +1,4 @@
import { IndexableType } from 'dexie';
import { App } from '@app/types';
import { dexieService } from '../DexieService';
@ -7,11 +8,11 @@ export class SetDTO extends App.Set {
return dexieService.sets.put(this);
}
static get(name) {
static get(name: string) {
return dexieService.sets.where('name').equalsIgnoreCase(name).first();
}
static bulkAdd(sets: SetDTO[]): Promise<any> {
static bulkAdd(sets: SetDTO[]): Promise<IndexableType> {
return dexieService.sets.bulkPut(sets);
}
};

View file

@ -3,7 +3,7 @@ import { App } from '@app/types';
import { dexieService } from '../DexieService';
export class SettingDTO extends App.Setting {
constructor(user) {
constructor(user: string) {
super();
this.user = user;
@ -14,7 +14,7 @@ export class SettingDTO extends App.Setting {
return dexieService.settings.put(this);
}
static get(user) {
static get(user: string) {
return dexieService.settings.where('user').equalsIgnoreCase(user).first();
}
};

View file

@ -1,3 +1,4 @@
import { IndexableType } from 'dexie';
import { App } from '@app/types';
import { dexieService } from '../DexieService';
@ -7,11 +8,11 @@ export class TokenDTO extends App.Token {
return dexieService.tokens.put(this);
}
static get(name) {
static get(name: string) {
return dexieService.tokens.where('name.value').equalsIgnoreCase(name).first();
}
static bulkAdd(tokens: TokenDTO[]): Promise<any> {
static bulkAdd(tokens: TokenDTO[]): Promise<IndexableType> {
return dexieService.tokens.bulkPut(tokens);
}
};

View file

@ -1,3 +1,5 @@
import Dexie from 'dexie';
export enum Stores {
SETTINGS = 'settings',
CARDS = 'cards',
@ -6,7 +8,7 @@ export enum Stores {
HOSTS = 'hosts',
}
export const schemaV1 = (db) => {
export const schemaV1 = (db: Dexie) => {
db.version(1).stores({
[Stores.CARDS]: 'name',
[Stores.SETS]: 'code',

View file

@ -1,3 +1,4 @@
export * from './CardImporterService';
export * from './HostService';
export * from './ServerProps';
export * from './dexie';

View file

@ -5,6 +5,43 @@ import './polyfills';
// Ensure jest-dom matchers are available in every test file.
import '@testing-library/jest-dom/vitest';
// jsdom doesn't provide ResizeObserver; react-window needs it.
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
// Mock Dexie globally to prevent IndexedDB initialization in jsdom.
// Dexie eagerly opens IndexedDB on import, and jsdom's fake-indexeddb
// is memory-intensive. No UI test needs a real database.
vi.mock('dexie', () => {
const fakeTable = {
mapToClass: () => {},
get: () => Promise.resolve(null),
put: () => Promise.resolve(),
add: () => Promise.resolve(1),
bulkAdd: () => Promise.resolve(),
delete: () => Promise.resolve(),
toArray: () => Promise.resolve([]),
where: () => ({ equals: () => ({ first: () => Promise.resolve(null) }) }),
};
class FakeDexie {
version() {
return { stores: () => this };
}
open() {
return Promise.resolve(this);
}
table() {
return fakeTable;
}
}
return { default: FakeDexie, __esModule: true };
});
// ── Global mock hygiene under `isolate: false` ────────────────────────────────
//
// Vitest is configured with `test.isolate: false` for speed — every spec file

View file

@ -1,9 +1,8 @@
import { actionReducer } from './actionReducer';
describe('actionReducer', () => {
it('spreads the init action onto state and starts count at 1', () => {
it('stores the init action type 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();
@ -11,7 +10,7 @@ describe('actionReducer', () => {
expect(result.count).toBe(1);
});
it('spreads action onto state and increments count', () => {
it('stores action type and cloned payload', () => {
const result = actionReducer(undefined, { type: 'MY_ACTION', payload: { id: 1 } });
expect(result.type).toBe('MY_ACTION');
expect(result.payload).toEqual({ id: 1 });
@ -25,16 +24,33 @@ describe('actionReducer', () => {
expect(state3.count).toBe(3);
});
it('preserves existing state fields not overridden by action', () => {
it('replaces type from previous action', () => {
const initial = actionReducer(undefined, { type: 'FIRST', payload: 'original' });
const result = actionReducer(initial, { type: 'SECOND' });
expect(result.type).toBe('SECOND');
expect(result.payload).toBeNull();
expect(result.count).toBe(2);
});
it('spreads action.meta and action.error from action onto state', () => {
it('stores meta and error from action', () => {
const result = actionReducer(undefined, { type: 'ERR', meta: { source: 'api' }, error: true });
expect(result.meta).toEqual({ source: 'api' });
expect(result.error).toBe(true);
});
it('deep-clones payload to prevent shared references', () => {
const nested = { data: { counterInfo: { id: 1, count: 20 } } };
const result = actionReducer(undefined, { type: 'TEST', payload: nested });
expect(result.payload).toEqual(nested);
expect(result.payload).not.toBe(nested);
expect((result.payload as any).data).not.toBe(nested.data);
expect((result.payload as any).data.counterInfo).not.toBe(nested.data.counterInfo);
});
it('deep-clones meta to prevent shared references', () => {
const meta = { source: { nested: true } };
const result = actionReducer(undefined, { type: 'TEST', meta });
expect(result.meta).toEqual(meta);
expect(result.meta).not.toBe(meta);
});
});

View file

@ -25,19 +25,21 @@ const initialState: InitialState = {
}
/**
* Calculates the application state.
* Stores the most recent action so `useReduxEffect` can react to dispatches.
*
* @param state
* @param action
* @return {*}
* Payloads are deep-cloned to prevent shared object references between this
* slice and the slice that owns the action. Without the clone, Immer mutations
* in the target slice are detected as mutations of the stale payload stored here.
*/
export const actionReducer = (
state = initialState,
action: UnknownAction,
): InitialState => {
return {
...state,
...action,
type: action.type ?? null,
payload: 'payload' in action ? structuredClone(action.payload) : null,
meta: 'meta' in action ? structuredClone(action.meta) : null,
error: !!action.error,
count: state.count + 1,
}
}

View file

@ -77,11 +77,23 @@ describe('normalizeLogs', () => {
const result = normalizeLogs(logs);
expect(result.room).toHaveLength(2);
expect(result.game).toHaveLength(1);
expect(result.chat).toBeUndefined();
expect(result.chat).toEqual([]);
});
it('returns empty object for empty logs', () => {
expect(normalizeLogs([])).toEqual({});
it('returns all three keys as empty arrays for empty logs', () => {
expect(normalizeLogs([])).toEqual({ room: [], game: [], chat: [] });
});
it('skips logs whose targetType is not one of the known buckets', () => {
const logs = [
create(Data.ServerInfo_ChatMessageSchema, { targetType: 'room' }),
create(Data.ServerInfo_ChatMessageSchema, { targetType: '' }),
create(Data.ServerInfo_ChatMessageSchema, { targetType: 'unknown' }),
];
const result = normalizeLogs(logs);
expect(result.room).toHaveLength(1);
expect(result.game).toEqual([]);
expect(result.chat).toEqual([]);
});
});

View file

@ -50,12 +50,13 @@ export function normalizeGameObject(game: Data.ServerInfo_Game, gametypeMap: Enr
/** Group a flat LogItem[] into { room, game, chat } buckets for the server store. */
export function normalizeLogs(logs: Data.ServerInfo_ChatMessage[]): Enriched.LogGroups {
return logs.reduce((obj, log) => {
return logs.reduce<Enriched.LogGroups>((obj, log) => {
const type = log.targetType as keyof Enriched.LogGroups;
obj[type] = obj[type] || [];
obj[type]!.push(log);
if (obj[type]) {
obj[type].push(log);
}
return obj;
}, {} as Enriched.LogGroups);
}, { room: [], game: [], chat: [] });
}
/**

View file

@ -197,7 +197,7 @@ describe('Dispatch', () => {
});
it('gameSay dispatches Actions.gameSay()', () => {
Dispatch.gameSay(1, 2, 'gg wp');
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay({ gameId: 1, playerId: 2, message: 'gg wp' }));
Dispatch.gameSay(1, 2, 'gg wp', 1700000000000);
expect(mockDispatch).toHaveBeenCalledWith(Actions.gameSay({ gameId: 1, playerId: 2, message: 'gg wp', timeReceived: 1700000000000 }));
});
});

View file

@ -127,7 +127,7 @@ export const Dispatch = {
store.dispatch(Actions.zonePropertiesChanged({ gameId, playerId, data }));
},
gameSay: (gameId: number, playerId: number, message: string) => {
store.dispatch(Actions.gameSay({ gameId, playerId, message }));
gameSay: (gameId: number, playerId: number, message: string, timeReceived: number) => {
store.dispatch(Actions.gameSay({ gameId, playerId, message, timeReceived }));
},
};

View file

@ -417,6 +417,69 @@ describe('2C: CARD_MOVED', () => {
expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(1);
expect(result.games[1].players[1].zones['nonexistent']).toBeUndefined();
});
it('CARD_MOVED → no-ops when neither cardId nor position resolve and newCardId < 0', () => {
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
deck: makeZoneEntry({ name: 'deck', cards: [], cardCount: 5 }),
hand: makeZoneEntry({ name: 'hand', cards: [], cardCount: 0 }),
},
}),
},
}),
},
});
const result = gamesReducer(state, Actions.cardMoved({
gameId: 1,
playerId: 1,
data: {
cardId: -1, cardName: '', startPlayerId: 1, startZone: 'deck',
position: -1, targetPlayerId: 1, targetZone: 'hand',
x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '',
},
}));
expect(result.games[1].players[1].zones['deck'].cardCount).toBe(5);
expect(cardsIn(result, 1, 1, 'hand')).toHaveLength(0);
});
it('CARD_MOVED → deep-clones counterList so moved card is independent', () => {
const cardCounter = create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 3 });
const card = makeCard({ id: 10, counterList: [cardCounter] });
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
hand: makeZoneEntry({ name: 'hand', cards: [card], cardCount: 1 }),
table: makeZoneEntry({ name: 'table', cardCount: 0 }),
},
}),
},
}),
},
});
const result = gamesReducer(state, Actions.cardMoved({
gameId: 1,
playerId: 1,
data: {
cardId: 10, cardName: '', startPlayerId: 1, startZone: 'hand',
position: -1, targetPlayerId: 1, targetZone: 'table',
x: 0, y: 0, newCardId: -1, faceDown: false, newCardProviderId: '',
},
}));
const movedCard = cardsIn(result, 1, 1, 'table')[0];
expect(movedCard.counterList).toHaveLength(1);
expect(movedCard.counterList).not.toBe(card.counterList);
});
});
// ── 2D: Card mutations ────────────────────────────────────────────────────────
@ -696,6 +759,17 @@ describe('2H: Player counters', () => {
expect(result.games[1].players[1].counters[5]).toEqual(counter);
});
it('COUNTER_CREATED → clones counterInfo to prevent shared references', () => {
const state = makeState();
const counter = makeCounter({ id: 5, name: 'Life', count: 20 });
const result = gamesReducer(state, Actions.counterCreated({
gameId: 1,
playerId: 1,
data: { counterInfo: counter },
}));
expect(result.games[1].players[1].counters[5]).not.toBe(counter);
});
it('COUNTER_SET → updates counter.count to new value', () => {
const counter = makeCounter({ id: 5, count: 20 });
const state = makeState({
@ -845,6 +919,34 @@ describe('2I: Zone operations', () => {
expect(cardsIn(result, 1, 1, 'deck')[1]).toEqual(newCard);
});
it('CARDS_REVEALED → clones counterList to prevent shared references', () => {
const cardCounter = create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 5 });
const revealedCard = makeCard({ id: 3, counterList: [cardCounter] });
const state = makeState({
games: {
1: makeGameEntry({
players: {
1: makePlayerEntry({
zones: {
deck: makeZoneEntry({ name: 'deck', cards: [], cardCount: 0 }),
},
}),
},
}),
},
});
const result = gamesReducer(state, Actions.cardsRevealed({
gameId: 1,
playerId: 1,
data: { zoneName: 'deck', cards: [revealedCard] },
}));
const stored = cardsIn(result, 1, 1, 'deck')[0];
expect(stored.counterList).toEqual(revealedCard.counterList);
expect(stored.counterList).not.toBe(revealedCard.counterList);
});
it('ZONE_PROPERTIES_CHANGED → sets alwaysRevealTopCard and alwaysLookAtTopCard', () => {
const state = makeState();
const result = gamesReducer(state, Actions.zonePropertiesChanged({
@ -882,21 +984,17 @@ describe('2J: Turn, phase, and chat', () => {
expect(result.games[1].reversed).toBe(true);
});
it('GAME_SAY → appends message with mocked Date.now() as timeReceived', () => {
it('GAME_SAY → appends message with timeReceived from payload', () => {
const state = makeState();
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456789);
try {
const result = gamesReducer(state, Actions.gameSay({
gameId: 1,
playerId: 2,
message: 'gg',
}));
const result = gamesReducer(state, Actions.gameSay({
gameId: 1,
playerId: 2,
message: 'gg',
timeReceived: 123456789,
}));
expect(result.games[1].messages).toHaveLength(1);
expect(result.games[1].messages[0]).toEqual({ playerId: 2, message: 'gg', timeReceived: 123456789 });
} finally {
dateNowSpy.mockRestore();
}
expect(result.games[1].messages).toHaveLength(1);
expect(result.games[1].messages[0]).toEqual({ playerId: 2, message: 'gg', timeReceived: 123456789 });
});
});
@ -1332,6 +1430,6 @@ describe('2L: Null-guard / missing entity early-returns', () => {
it('GAME_SAY with unknown gameId → state unchanged', () => {
const state = makeState();
expect(gamesReducer(state, Actions.gameSay({ gameId: UNKNOWN_GAME, playerId: 1, message: 'hi' }))).toBe(state);
expect(gamesReducer(state, Actions.gameSay({ gameId: UNKNOWN_GAME, playerId: 1, message: 'hi', timeReceived: 0 }))).toBe(state);
});
});

View file

@ -183,7 +183,7 @@ export const gamesSlice = createSlice({
state,
action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_MoveCard }>,
) => {
const { gameId, playerId, data } = action.payload;
const { gameId, data } = action.payload;
const {
cardId, cardName, startPlayerId, startZone, position,
targetPlayerId, targetZone, x, y, newCardId, faceDown, newCardProviderId,
@ -194,8 +194,7 @@ export const gamesSlice = createSlice({
return;
}
const effectiveStartPlayerId = startPlayerId >= 0 ? startPlayerId : playerId;
const sourcePlayer = game.players[effectiveStartPlayerId];
const sourcePlayer = game.players[startPlayerId];
const sourceZone = sourcePlayer?.zones[startZone];
if (!sourcePlayer || !sourceZone) {
return;
@ -214,10 +213,16 @@ export const gamesSlice = createSlice({
resolvedCardId = sourceZone.order[position];
}
const removedCard: Data.ServerInfo_Card | undefined =
resolvedCardId >= 0 ? sourceZone.byId[resolvedCardId] : undefined;
// If the card can't be resolved and no newCardId is provided, the event
// is malformed — bail out to avoid creating phantom cards with id -1.
if (resolvedCardId < 0 && newCardId < 0) {
return;
}
// Remove from source zone if the card was resolved to a known entry
let removedCard: Data.ServerInfo_Card | undefined;
if (resolvedCardId >= 0) {
removedCard = sourceZone.byId[resolvedCardId];
const idx = sourceZone.order.indexOf(resolvedCardId);
if (idx >= 0) {
sourceZone.order.splice(idx, 1);
@ -226,11 +231,12 @@ export const gamesSlice = createSlice({
}
sourceZone.cardCount = Math.max(0, sourceZone.cardCount - 1);
const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1);
const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? resolvedCardId);
const movedCard: Data.ServerInfo_Card = removedCard
? {
...removedCard, id: effectiveNewId, name: cardName || removedCard.name,
x, y, faceDown, providerId: newCardProviderId || removedCard.providerId,
counterList: [...removedCard.counterList],
}
: buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? '');
@ -342,8 +348,8 @@ export const gamesSlice = createSlice({
arrowCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateArrow }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
player.arrows[data.arrowInfo.id] = data.arrowInfo;
if (player && data.arrowInfo) {
player.arrows[data.arrowInfo.id] = { ...data.arrowInfo };
}
},
@ -360,8 +366,8 @@ export const gamesSlice = createSlice({
counterCreated: (state, action: PayloadAction<{ gameId: number; playerId: number; data: Data.Event_CreateCounter }>) => {
const { gameId, playerId, data } = action.payload;
const player = state.games[gameId]?.players[playerId];
if (player) {
player.counters[data.counterInfo.id] = data.counterInfo;
if (player && data.counterInfo) {
player.counters[data.counterInfo.id] = { ...data.counterInfo };
}
},
@ -417,12 +423,10 @@ export const gamesSlice = createSlice({
}
for (const revealedCard of cards) {
if (zone.byId[revealedCard.id]) {
Object.assign(zone.byId[revealedCard.id], revealedCard);
} else {
if (!zone.byId[revealedCard.id]) {
zone.order.push(revealedCard.id);
zone.byId[revealedCard.id] = revealedCard;
}
zone.byId[revealedCard.id] = { ...revealedCard, counterList: [...revealedCard.counterList] };
}
},
@ -465,8 +469,8 @@ export const gamesSlice = createSlice({
// ── Chat ──────────────────────────────────────────────────────────────────
gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string }>) => {
const { gameId, playerId, message } = action.payload;
gameSay: (state, action: PayloadAction<{ gameId: number; playerId: number; message: string; timeReceived: number }>) => {
const { gameId, playerId, message, timeReceived } = action.payload;
const game = state.games[gameId];
if (!game) {
return;
@ -474,7 +478,7 @@ export const gamesSlice = createSlice({
if (game.messages.length >= MAX_GAME_MESSAGES) {
game.messages = game.messages.slice(game.messages.length - MAX_GAME_MESSAGES + 1);
}
game.messages.push({ playerId, message, timeReceived: Date.now() });
game.messages.push({ playerId, message, timeReceived });
},
// ── Log-only events ─────────────────────────────────────────────────────

View file

@ -42,9 +42,10 @@ describe('Actions', () => {
});
it('sortGames', () => {
expect(Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })).toEqual({
expect(Actions.sortGames({ roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })).toEqual({
type: Types.SORT_GAMES,
payload: {
roomId: 1,
field: App.GameSortField.START_TIME,
order: App.SortDirection.ASC,
},

View file

@ -74,7 +74,7 @@ describe('Dispatch', () => {
it('sortGames dispatches Actions.sortGames()', () => {
Dispatch.sortGames(1, App.GameSortField.START_TIME, App.SortDirection.ASC);
expect(mockDispatch).toHaveBeenCalledWith(
Actions.sortGames({ field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })
Actions.sortGames({ roomId: 1, field: App.GameSortField.START_TIME, order: App.SortDirection.ASC })
);
});

View file

@ -37,7 +37,7 @@ export const Dispatch = {
},
sortGames: (roomId: number, field: App.GameSortField, order: App.SortDirection) => {
store.dispatch(Actions.sortGames({ field, order }));
store.dispatch(Actions.sortGames({ roomId, field, order }));
},
removeMessages: (roomId: number, name: string, amount: number) => {

View file

@ -243,6 +243,7 @@ describe('SORT_GAMES', () => {
it('updates sortGamesBy on state (sorting itself is now derived in selectors)', () => {
const state = makeRoomsState({ rooms: {} });
const result = roomsReducer(state, Actions.sortGames({
roomId: 1,
field: App.GameSortField.START_TIME,
order: App.SortDirection.ASC,
}));

View file

@ -82,14 +82,14 @@ export const roomsSlice = createSlice({
addMessage: (state, action: PayloadAction<{ roomId: number; message: Enriched.Message }>) => {
const { roomId, message } = action.payload;
const existing = state.messages[roomId] ?? [];
const normalized = normalizeUserMessage(message);
const next =
existing.length >= MAX_ROOM_MESSAGES
? [...existing.slice(existing.length - MAX_ROOM_MESSAGES + 1), normalized]
: [...existing, normalized];
state.messages[roomId] = next;
if (!state.messages[roomId]) {
state.messages[roomId] = [];
}
const msgs = state.messages[roomId];
if (msgs.length >= MAX_ROOM_MESSAGES) {
state.messages[roomId] = msgs.slice(msgs.length - MAX_ROOM_MESSAGES + 1);
}
state.messages[roomId].push(normalizeUserMessage(message));
},
updateGames: (state, action: PayloadAction<{ roomId: number; games: Data.ServerInfo_Game[] }>) => {
@ -145,8 +145,9 @@ export const roomsSlice = createSlice({
delete room.users[name];
},
sortGames: (state, action: PayloadAction<{ field: App.GameSortField; order: App.SortDirection }>) => {
// Sort is now derived in selectors; the reducer only stores the sort config.
sortGames: (state, action: PayloadAction<{ roomId: number; field: App.GameSortField; order: App.SortDirection }>) => {
// Sort is derived in selectors; the reducer stores the sort config.
// roomId is passed through for future per-room sorting support.
const { field, order } = action.payload;
state.sortGamesBy = { field, order };
},

View file

@ -143,7 +143,7 @@ export function makeServerState(overrides: Partial<ServerState> = {}): ServerSta
ignoreList: {},
status: {
connectionAttemptMade: false,
state: App.StatusEnum.DISCONNECTED,
state: Enriched.StatusEnum.DISCONNECTED,
description: null,
},
info: {

View file

@ -1,5 +1,5 @@
import { Actions } from './server.actions';
import { App, Data } from '@app/types';
import { Data, Enriched } from '@app/types';
import { Types } from './server.types';
import { create } from '@bufbuild/protobuf';
import {
@ -88,7 +88,7 @@ describe('Actions', () => {
});
it('updateStatus', () => {
const status = { state: App.StatusEnum.CONNECTED, description: 'connected' };
const status = { state: Enriched.StatusEnum.CONNECTED, description: 'connected' };
expect(Actions.updateStatus({ status })).toEqual({ type: Types.UPDATE_STATUS, payload: { status } });
});

View file

@ -6,7 +6,7 @@ vi.mock('..', () => ({ store: { dispatch: mockDispatch } }));
import { Actions } from './server.actions';
import { Dispatch } from './server.dispatch';
import { App, Data } from '@app/types';
import { Data, Enriched } from '@app/types';
import { create } from '@bufbuild/protobuf';
import {
makeBanHistoryItem,
@ -106,8 +106,10 @@ describe('Dispatch', () => {
});
it('updateStatus dispatches Actions.updateStatus({ status: { state, description } })', () => {
Dispatch.updateStatus(App.StatusEnum.CONNECTED, 'ok');
expect(mockDispatch).toHaveBeenCalledWith(Actions.updateStatus({ status: { state: App.StatusEnum.CONNECTED, description: 'ok' } }));
Dispatch.updateStatus(Enriched.StatusEnum.CONNECTED, 'ok');
expect(mockDispatch).toHaveBeenCalledWith(
Actions.updateStatus({ status: { state: Enriched.StatusEnum.CONNECTED, description: 'ok' } })
);
});
it('updateUser dispatches Actions.updateUser()', () => {

View file

@ -1,6 +1,6 @@
import { Actions } from './server.actions';
import { store } from '..';
import { App, Data, Enriched } from '@app/types';
import { Data, Enriched } from '@app/types';
export const Dispatch = {
initialized: () => {
@ -48,7 +48,7 @@ export const Dispatch = {
updateInfo: (name: string, version: string) => {
store.dispatch(Actions.updateInfo({ info: { name, version } }));
},
updateStatus: (state: App.StatusEnum, description: string) => {
updateStatus: (state: Enriched.StatusEnum, description: string) => {
store.dispatch(Actions.updateStatus({ status: { state, description } }));
},
updateUser: (user: Data.ServerInfo_User) => {

View file

@ -43,7 +43,7 @@ export interface ServerState {
export interface ServerStateStatus {
connectionAttemptMade: boolean;
description: string | null;
state: App.StatusEnum;
state: Enriched.StatusEnum;
}
export interface ServerStateInfo {

View file

@ -1,6 +1,6 @@
import { App, Data } from '@app/types';
import { Data, Enriched } from '@app/types';
import { create } from '@bufbuild/protobuf';
import { serverReducer } from './server.reducer';
import { serverReducer, MAX_USER_MESSAGES } from './server.reducer';
import { Actions } from './server.actions';
import {
makeBanHistoryItem,
@ -25,7 +25,7 @@ describe('Initialisation', () => {
const result = serverReducer(undefined, { type: '@@INIT' });
expect(result.initialized).toBe(false);
expect(result.buddyList).toEqual({});
expect(result.status.state).toBe(App.StatusEnum.DISCONNECTED);
expect(result.status.state).toBe(Enriched.StatusEnum.DISCONNECTED);
});
it('INITIALIZED → resets to initialState with initialized: true', () => {
@ -37,7 +37,7 @@ describe('Initialisation', () => {
});
it('CLEAR_STORE → resets to initialState but preserves status', () => {
const status = { state: App.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true };
const status = { state: Enriched.StatusEnum.LOGGED_IN, description: 'logged in', connectionAttemptMade: true };
const state = makeServerState({ status, banUser: 'someone' });
const result = serverReducer(state, Actions.clearStore());
expect(result.banUser).toBe('');
@ -56,7 +56,7 @@ describe('Initialisation', () => {
describe('Account & Connection', () => {
it('CONNECTION_ATTEMPTED → sets connectionAttemptMade to true', () => {
const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } });
const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } });
const result = serverReducer(state, Actions.connectionAttempted());
expect(result.status.connectionAttemptMade).toBe(true);
});
@ -131,9 +131,9 @@ describe('Server Info & Status', () => {
it('UPDATE_STATUS → merges state and description into status', () => {
const state = makeServerState();
const update = { state: App.StatusEnum.LOGGED_IN, description: 'ok' };
const update = { state: Enriched.StatusEnum.LOGGED_IN, description: 'ok' };
const result = serverReducer(state, Actions.updateStatus({ status: update }));
expect(result.status.state).toBe(App.StatusEnum.LOGGED_IN);
expect(result.status.state).toBe(Enriched.StatusEnum.LOGGED_IN);
expect(result.status.description).toBe('ok');
expect(result.status.connectionAttemptMade).toBe(false);
});
@ -256,6 +256,14 @@ describe('Logs', () => {
expect(result.logs.room).toEqual([log]);
});
it('VIEW_LOGS with empty array → produces all three keys as empty arrays', () => {
const state = makeServerState();
const result = serverReducer(state, Actions.viewLogs({ logs: [] }));
expect(result.logs.room).toEqual([]);
expect(result.logs.game).toEqual([]);
expect(result.logs.chat).toEqual([]);
});
it('CLEAR_LOGS → resets logs to empty arrays', () => {
const state = makeServerState({ logs: { room: [makeLogItem()], game: [], chat: [] } });
const result = serverReducer(state, Actions.clearLogs());
@ -284,6 +292,13 @@ describe('Messaging', () => {
expect(result.messages['Alice'][0]).toEqual(messageData);
});
it('USER_MESSAGE → no-ops when user is null (not yet logged in)', () => {
const state = makeServerState({ user: null, messages: {} });
const messageData = { senderName: 'Alice', receiverName: 'Bob', message: 'hi' } as Data.Event_UserMessage;
const result = serverReducer(state, Actions.userMessage({ messageData }));
expect(result.messages).toEqual({});
});
it('USER_MESSAGE → appends to existing messages for that user', () => {
const existingMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'first' });
const state = makeServerState({
@ -294,6 +309,21 @@ describe('Messaging', () => {
const result = serverReducer(state, Actions.userMessage({ messageData: newMsg }));
expect(result.messages['Alice']).toHaveLength(2);
});
it(`USER_MESSAGE → caps messages at MAX_USER_MESSAGES (${MAX_USER_MESSAGES})`, () => {
const messages = Array.from({ length: MAX_USER_MESSAGES }, (_, i) =>
create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: `msg-${i}` })
);
const state = makeServerState({
user: makeUser({ name: 'Bob' }),
messages: { Alice: messages },
});
const newMsg = create(Data.Event_UserMessageSchema, { senderName: 'Alice', receiverName: 'Bob', message: 'overflow' });
const result = serverReducer(state, Actions.userMessage({ messageData: newMsg }));
expect(result.messages['Alice']).toHaveLength(MAX_USER_MESSAGES);
expect(result.messages['Alice'][MAX_USER_MESSAGES - 1]).toEqual(newMsg);
expect(result.messages['Alice'][0].message).not.toBe('msg-0');
});
});
// ── User Info & Notifications ─────────────────────────────────────────────────

View file

@ -6,6 +6,8 @@ import { normalizeBannedUserError, normalizeGameObject, normalizeGametypeMap, no
import { ServerState, ServerStateStatus } from './server.interfaces';
export const MAX_USER_MESSAGES = 1000;
function splitPath(path: string): string[] {
return path ? path.split('/') : [];
}
@ -71,7 +73,7 @@ const initialState: ServerState = {
status: {
connectionAttemptMade: false,
state: App.StatusEnum.DISCONNECTED,
state: Enriched.StatusEnum.DISCONNECTED,
description: null
},
info: {
@ -172,17 +174,20 @@ export const serverSlice = createSlice({
updateStatus: (state, action: PayloadAction<{ status: Pick<ServerStateStatus, 'state' | 'description'> }>) => {
const { status } = action.payload;
state.status = { ...state.status, ...status };
state.status.state = status.state;
state.status.description = status.description;
if (status.state === App.StatusEnum.DISCONNECTED) {
if (status.state === Enriched.StatusEnum.DISCONNECTED) {
state.status.connectionAttemptMade = false;
}
},
updateUser: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
state.user = state.user
? { ...state.user, ...action.payload.user } as Data.ServerInfo_User
: action.payload.user as Data.ServerInfo_User;
if (state.user) {
Object.assign(state.user, action.payload.user);
} else {
state.user = action.payload.user as Data.ServerInfo_User;
}
},
updateUsers: (state, action: PayloadAction<{ users: Data.ServerInfo_User[] }>) => {
@ -203,7 +208,7 @@ export const serverSlice = createSlice({
},
viewLogs: (state, action: PayloadAction<{ logs: Data.ServerInfo_ChatMessage[] }>) => {
state.logs = { ...normalizeLogs(action.payload.logs) };
state.logs = normalizeLogs(action.payload.logs);
},
clearLogs: (state) => {
@ -211,11 +216,18 @@ export const serverSlice = createSlice({
},
userMessage: (state, action: PayloadAction<{ messageData: Data.Event_UserMessage }>) => {
if (!state.user) {
return;
}
const { senderName, receiverName } = action.payload.messageData;
const userName = state.user!.name === senderName ? receiverName : senderName;
const userName = state.user.name === senderName ? receiverName : senderName;
if (!state.messages[userName]) {
state.messages[userName] = [];
}
const msgs = state.messages[userName];
if (msgs.length >= MAX_USER_MESSAGES) {
state.messages[userName] = msgs.slice(msgs.length - MAX_USER_MESSAGES + 1);
}
state.messages[userName].push(action.payload.messageData);
},
@ -273,7 +285,7 @@ export const serverSlice = createSlice({
newLevel = shouldBeJudge
? (newLevel | Data.ServerInfo_User_UserLevelFlag.IsJudge)
: (newLevel & ~Data.ServerInfo_User_UserLevelFlag.IsJudge);
state.users[userName] = { ...user, userLevel: newLevel };
user.userLevel = newLevel;
},
replayList: (state, action: PayloadAction<{ matchList: Data.ServerInfo_ReplayMatch[] }>) => {
@ -295,7 +307,7 @@ export const serverSlice = createSlice({
if (!existing) {
return;
}
state.replays[gameId] = { ...existing, doNotHide };
existing.doNotHide = doNotHide;
},
replayDeleteMatch: (state, action: PayloadAction<{ gameId: number }>) => {
@ -379,39 +391,43 @@ export const serverSlice = createSlice({
},
accountEditChanged: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User;
if (state.user) {
Object.assign(state.user, action.payload.user);
}
},
accountImageChanged: (state, action: PayloadAction<{ user: Partial<Data.ServerInfo_User> }>) => {
state.user = { ...state.user, ...action.payload.user } as Data.ServerInfo_User;
if (state.user) {
Object.assign(state.user, action.payload.user);
}
},
// Signal-only action types — no state mutation, defined so type strings are generated
accountAwaitingActivation: (_state, _action: PayloadAction<any>) => {},
accountActivationFailed: (_state, _action: PayloadAction<any>) => {},
accountActivationSuccess: (_state, _action: PayloadAction<any>) => {},
loginSuccessful: (_state, _action: PayloadAction<any>) => {},
loginFailed: (_state, _action: PayloadAction<any>) => {},
connectionFailed: (_state, _action: PayloadAction<any>) => {},
testConnectionSuccessful: (_state, _action: PayloadAction<any>) => {},
testConnectionFailed: (_state, _action: PayloadAction<any>) => {},
registrationRequiresEmail: (_state, _action: PayloadAction<any>) => {},
registrationSuccess: (_state, _action: PayloadAction<any>) => {},
registrationEmailError: (_state, _action: PayloadAction<any>) => {},
registrationPasswordError: (_state, _action: PayloadAction<any>) => {},
registrationUserNameError: (_state, _action: PayloadAction<any>) => {},
resetPassword: (_state, _action: PayloadAction<any>) => {},
resetPasswordFailed: (_state, _action: PayloadAction<any>) => {},
resetPasswordChallenge: (_state, _action: PayloadAction<any>) => {},
resetPasswordSuccess: (_state, _action: PayloadAction<any>) => {},
reloadConfig: (_state, _action: PayloadAction<any>) => {},
shutdownServer: (_state, _action: PayloadAction<any>) => {},
updateServerMessage: (_state, _action: PayloadAction<any>) => {},
accountPasswordChange: (_state, _action: PayloadAction<any>) => {},
addToList: (_state, _action: PayloadAction<any>) => {},
removeFromList: (_state, _action: PayloadAction<any>) => {},
grantReplayAccess: (_state, _action: PayloadAction<any>) => {},
forceActivateUser: (_state, _action: PayloadAction<any>) => {},
accountAwaitingActivation: (_state, _action: PayloadAction<{ options: Enriched.PendingActivationContext }>) => {},
accountActivationFailed: (_state) => {},
accountActivationSuccess: (_state) => {},
loginSuccessful: (_state, _action: PayloadAction<{ options: Enriched.LoginSuccessContext }>) => {},
loginFailed: (_state) => {},
connectionFailed: (_state) => {},
testConnectionSuccessful: (_state) => {},
testConnectionFailed: (_state) => {},
registrationRequiresEmail: (_state) => {},
registrationSuccess: (_state) => {},
registrationEmailError: (_state, _action: PayloadAction<{ error: string }>) => {},
registrationPasswordError: (_state, _action: PayloadAction<{ error: string }>) => {},
registrationUserNameError: (_state, _action: PayloadAction<{ error: string }>) => {},
resetPassword: (_state) => {},
resetPasswordFailed: (_state) => {},
resetPasswordChallenge: (_state) => {},
resetPasswordSuccess: (_state) => {},
reloadConfig: (_state) => {},
shutdownServer: (_state) => {},
updateServerMessage: (_state) => {},
accountPasswordChange: (_state) => {},
addToList: (_state, _action: PayloadAction<{ list: string; userName: string }>) => {},
removeFromList: (_state, _action: PayloadAction<{ list: string; userName: string }>) => {},
grantReplayAccess: (_state, _action: PayloadAction<{ replayId: number; moderatorName: string }>) => {},
forceActivateUser: (_state, _action: PayloadAction<{ usernameToActivate: string; moderatorName: string }>) => {},
},
});

View file

@ -6,7 +6,7 @@ import {
makeServerState,
makeUser,
} from './__mocks__/server-fixtures';
import { App, Data } from '@app/types';
import { Data, Enriched } from '@app/types';
function rootState(server: ServerState) {
return { server };
@ -34,17 +34,17 @@ describe('Selectors', () => {
});
it('getDescription → returns status.description', () => {
const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.CONNECTED, description: 'ok' } });
const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.CONNECTED, description: 'ok' } });
expect(Selectors.getDescription(rootState(state))).toBe('ok');
});
it('getState → returns status.state', () => {
const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.LOGGED_IN, description: null } });
expect(Selectors.getState(rootState(state))).toBe(App.StatusEnum.LOGGED_IN);
const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.LOGGED_IN, description: null } });
expect(Selectors.getState(rootState(state))).toBe(Enriched.StatusEnum.LOGGED_IN);
});
it('getConnectionAttemptMade → returns status.connectionAttemptMade', () => {
const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.DISCONNECTED, description: null } });
const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.DISCONNECTED, description: null } });
expect(Selectors.getConnectionAttemptMade(rootState(state))).toBe(true);
});
@ -153,17 +153,17 @@ describe('Selectors', () => {
// ── derived selectors (createSelector) ──────────────────────────────
it('getIsConnected → true when state is LOGGED_IN', () => {
const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } });
const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } });
expect(Selectors.getIsConnected(rootState(state))).toBe(true);
});
it('getIsConnected → false when state is CONNECTED', () => {
const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.CONNECTED, description: null } });
const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.CONNECTED, description: null } });
expect(Selectors.getIsConnected(rootState(state))).toBe(false);
});
it('getIsConnected → false when state is DISCONNECTED', () => {
const state = makeServerState({ status: { connectionAttemptMade: false, state: App.StatusEnum.DISCONNECTED, description: null } });
const state = makeServerState({ status: { connectionAttemptMade: false, state: Enriched.StatusEnum.DISCONNECTED, description: null } });
expect(Selectors.getIsConnected(rootState(state))).toBe(false);
});
@ -189,7 +189,7 @@ describe('Selectors', () => {
// ── createSelector reference stability ──────────────────────────────
it('getIsConnected → returns same value reference for identical state', () => {
const state = makeServerState({ status: { connectionAttemptMade: true, state: App.StatusEnum.LOGGED_IN, description: null } });
const state = makeServerState({ status: { connectionAttemptMade: true, state: Enriched.StatusEnum.LOGGED_IN, description: null } });
const root = rootState(state);
const a = Selectors.getIsConnected(root);
const b = Selectors.getIsConnected(root);

View file

@ -1,5 +1,5 @@
import { createSelector } from '@reduxjs/toolkit';
import { App, Data } from '@app/types';
import { Data, Enriched } from '@app/types';
import { SortUtil } from '../common';
import { ServerState } from './server.interfaces';
@ -23,7 +23,7 @@ export const Selectors = {
/** True when the server status has reached LOGGED_IN. */
getIsConnected: createSelector(
[({ server }: State) => server.status.state],
(state): boolean => state === App.StatusEnum.LOGGED_IN
(state): boolean => state === Enriched.StatusEnum.LOGGED_IN
),
/** True when the currently logged-in user has the IsModerator level flag. */

View file

@ -1,5 +1,5 @@
export * from './cards';
export * from './constants';
export * from './regex-patterns';
export * from './countries';
export * from './languages';
export * from './routes';

View file

@ -250,4 +250,6 @@ export const countryCodes = [
'XK',
'ZM',
'ZW',
];
] as const;
export type CountryCode = typeof countryCodes[number];

View file

@ -1,6 +1,5 @@
import type {
Event_RoomSay,
GameEventContext,
ServerInfo_Arrow,
ServerInfo_Card,
ServerInfo_ChatMessage,
@ -110,32 +109,20 @@ export interface GameMessage {
timeReceived: number;
}
/**
* 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 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;
}
export interface LogGroups {
room: ServerInfo_ChatMessage[];
game: ServerInfo_ChatMessage[];
chat: ServerInfo_ChatMessage[];
}
// ── Connect options (re-exported from @app/websocket) ────────────────────────
// Source of truth lives in src/websocket/connectOptions.ts. Re-exported here
// so UI code can use the Enriched.* namespace without importing @app/websocket.
// ── Websocket re-exports ─────────────────────────────────────────────────────
// Source of truth lives in @app/websocket. Re-exported here so app code can
// reach these via the Enriched.* namespace without importing @app/websocket.
export { StatusEnum, WebSocketConnectReason } from '@app/websocket';
export type {
GameEventMeta,
LoginConnectOptions,
RegisterConnectOptions,
ActivateConnectOptions,

View file

@ -2,7 +2,7 @@ import {
URL_REGEX,
MESSAGE_SENDER_REGEX,
MENTION_REGEX,
} from './constants';
} from './regex-patterns';
describe('RegEx', () => {
describe('URL_REGEX', () => {

View file

@ -1,5 +1,4 @@
export { StatusEnum, WebSocketConnectReason } from '@app/websocket';
import type { StatusEnum } from '@app/websocket';
import type { StatusEnum } from './enriched';
export interface ServerStatus {
status: StatusEnum;
@ -19,59 +18,3 @@ export class Host {
hashedPassword?: string;
remember?: boolean;
}
export const DefaultHosts: Host[] = [
{
name: 'Chickatrice',
host: 'mtg.chickatrice.net',
port: '443',
localPort: '4748',
editable: false,
},
{
name: 'Rooster',
host: 'server.cockatrice.us/servatrice',
port: '4748',
localHost: 'server.cockatrice.us',
editable: false,
},
{
name: 'Rooster Beta',
host: 'beta.cockatrice.us/servatrice',
port: '4748',
localHost: 'beta.cockatrice.us',
editable: false,
},
{
name: 'Tetrarch',
host: 'mtg.tetrarch.co/servatrice',
port: '443',
editable: false,
},
];
export const getHostPort = (host: Host): { host: string, port: string } => {
const isLocal = window.location.hostname === 'localhost';
if (!host) {
return {
host: '',
port: ''
};
}
return {
host: !isLocal ? host.host : host.localHost || host.host,
port: !isLocal ? host.port : host.localPort || host.port,
}
};
export enum KnownHost {
ROOSTER = 'Rooster',
TETRARCH = 'Tetrarch',
}
export const KnownHosts = {
[KnownHost.ROOSTER]: { port: 4748, host: 'server.cockatrice.us', },
[KnownHost.TETRARCH]: { port: 443, host: 'mtg.tetrarch.co/servatrice' },
}

View file

@ -18,7 +18,7 @@ import { Mock } from 'vitest';
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
import { WebClient } from '../../WebClient';
import * as SessionIndexMocks from './';
import { App, Enriched } from '@app/types';
import { Enriched } from '@app/types';
import { StatusEnum } from '../../interfaces/StatusEnum';
import {
Command_Activate_ext,
@ -59,7 +59,7 @@ const baseTransport = { host: 'h', port: '1' };
const makeLoginOpts = (overrides: Partial<Enriched.LoginConnectOptions> = {}): Enriched.LoginConnectOptions => ({
...baseTransport,
userName: 'alice',
reason: App.WebSocketConnectReason.LOGIN,
reason: Enriched.WebSocketConnectReason.LOGIN,
...overrides,
});
const makeRegisterOpts = (
@ -71,7 +71,7 @@ const makeRegisterOpts = (
email: 'a@b.com',
country: 'US',
realName: 'Al',
reason: App.WebSocketConnectReason.REGISTER,
reason: Enriched.WebSocketConnectReason.REGISTER,
...overrides,
});
const makeActivateOpts = (
@ -80,26 +80,26 @@ const makeActivateOpts = (
...baseTransport,
userName: 'alice',
token: 'tok',
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT,
reason: Enriched.WebSocketConnectReason.ACTIVATE_ACCOUNT,
...overrides,
});
const makeForgotRequestOpts = (): Enriched.PasswordResetRequestConnectOptions => ({
...baseTransport,
userName: 'alice',
reason: App.WebSocketConnectReason.PASSWORD_RESET_REQUEST,
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_REQUEST,
});
const makeForgotChallengeOpts = (): Enriched.PasswordResetChallengeConnectOptions => ({
...baseTransport,
userName: 'alice',
email: 'a@b.com',
reason: App.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE,
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE,
});
const makeForgotResetOpts = (): Enriched.PasswordResetConnectOptions => ({
...baseTransport,
userName: 'alice',
token: 'tok',
newPassword: 'newpw',
reason: App.WebSocketConnectReason.PASSWORD_RESET,
reason: Enriched.WebSocketConnectReason.PASSWORD_RESET,
});
@ -194,7 +194,7 @@ describe('login', () => {
});
it('onSuccess passes hashedPassword to loginSuccessful when salt is used', () => {
login({ host: 'h', port: '1', userName: 'alice', reason: App.WebSocketConnectReason.LOGIN }, 'pw', 'salt');
login({ host: 'h', port: '1', userName: 'alice', reason: Enriched.WebSocketConnectReason.LOGIN }, 'pw', 'salt');
const loginResp = { buddyList: [], ignoreList: [], userInfo: { name: 'alice' } };
invokeOnSuccess(loginResp, { responseCode: 0 });
const calledWith = (WebClient.instance.response.session.loginSuccessful as Mock).mock.calls[0][0];

View file

@ -115,10 +115,10 @@ describe('playerPropertiesChanged event', () => {
});
describe('gameSay event', () => {
it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message', () => {
it('delegates to WebClient.instance.response.game.gameSay with gameId, playerId, message, timeReceived', () => {
const data = create(Event_GameSaySchema, { message: 'gg' });
gameSay(data, meta);
expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg');
expect(WebClient.instance.response.game.gameSay).toHaveBeenCalledWith(5, 2, 'gg', expect.any(Number));
});
});

View file

@ -3,5 +3,5 @@ import type { GameEventMeta } from '../../interfaces/WebSocketConfig';
import { WebClient } from '../../WebClient';
export function gameSay(data: Event_GameSay, meta: GameEventMeta): void {
WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message);
WebClient.instance.response.game.gameSay(meta.gameId, meta.playerId, data.message, Date.now());
}

View file

@ -134,7 +134,7 @@ export interface IGameResponse {
gameClosed(gameId: number): void;
gameHostChanged(gameId: number, hostId: number): void;
kicked(gameId: number): void;
gameSay(gameId: number, playerId: number, message: string): void;
gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void;
cardMoved(gameId: number, playerId: number, data: Event_MoveCard): void;
cardFlipped(gameId: number, playerId: number, data: Event_FlipCard): void;
cardDestroyed(gameId: number, playerId: number, data: Event_DestroyCard): void;

View file

@ -64,5 +64,14 @@ describe('KeepAliveService', () => {
expect(service.endPingLoop).toHaveBeenCalled();
});
it('should clear previous interval when startPingLoop is called again', () => {
const clearSpy = vi.spyOn(globalThis, 'clearInterval');
const previousCb = (service as KeepAliveInternal).keepalivecb;
service.startPingLoop(interval, ping);
expect(clearSpy).toHaveBeenCalledWith(previousCb);
});
});
});

View file

@ -13,6 +13,7 @@ export class KeepAliveService {
}
public startPingLoop(interval: number, ping: (onPong: () => void) => void): void {
this.endPingLoop();
this.keepalivecb = setInterval(() => {
// check if the previous ping got no reply
if (this.lastPingPending) {

View file

@ -112,6 +112,67 @@ describe('ProtobufService', () => {
expect((service as ProtobufInternal).cmdId).toBe(0);
expect((service as ProtobufInternal).pendingCommands.size).toBe(0);
});
it('returns true when command is sent', () => {
const service = new ProtobufService(mockSocket);
const result = service.sendCommand(create(CommandContainerSchema), vi.fn());
expect(result).toBe(true);
});
it('returns false when transport is closed', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const result = service.sendCommand(create(CommandContainerSchema), vi.fn());
expect(result).toBe(false);
});
});
describe('send*Command when transport is closed', () => {
it('calls onError when sendSessionCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendSessionCommand(sessionExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendRoomCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendRoomCommand(42, roomExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendGameCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendGameCommand(7, gameExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendModeratorCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendModeratorCommand(moderatorExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('calls onError when sendAdminCommand is dropped', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
const onError = vi.fn();
service.sendAdminCommand(adminExt, {}, { onError });
expect(onError).toHaveBeenCalledWith(-1, expect.any(Object));
});
it('does not throw when command is dropped with no options', () => {
const service = new ProtobufService(mockSocket);
mockSocket.isOpen.mockReturnValue(false);
expect(() => service.sendSessionCommand(sessionExt, {})).not.toThrow();
});
});
describe('sendSessionCommand', () => {
@ -311,9 +372,9 @@ describe('ProtobufService', () => {
expect(processGameEvent).toHaveBeenCalled();
});
it('logs unknown message types (default case)', () => {
it('warns on unknown message types (default case)', () => {
const service = new ProtobufService(mockSocket);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.mocked(fromBinary).mockReturnValue(
create(ServerMessageSchema, {

View file

@ -54,11 +54,7 @@ export class ProtobufService {
const gameCmd = create(GameCommandSchema);
setExtension(gameCmd, ext, value);
const cmd = create(CommandContainerSchema, { gameId, gameCommand: [gameCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendRoomCommand<V, R = unknown>(
@ -70,11 +66,7 @@ export class ProtobufService {
const roomCmd = create(RoomCommandSchema);
setExtension(roomCmd, ext, value);
const cmd = create(CommandContainerSchema, { roomId, roomCommand: [roomCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendSessionCommand<V, R = unknown>(
@ -85,11 +77,7 @@ export class ProtobufService {
const sesCmd = create(SessionCommandSchema);
setExtension(sesCmd, ext, value);
const cmd = create(CommandContainerSchema, { sessionCommand: [sesCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendModeratorCommand<V, R = unknown>(
@ -100,11 +88,7 @@ export class ProtobufService {
const modCmd = create(ModeratorCommandSchema);
setExtension(modCmd, ext, value);
const cmd = create(CommandContainerSchema, { moderatorCommand: [modCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendAdminCommand<V, R = unknown>(
@ -115,22 +99,31 @@ export class ProtobufService {
const adminCmd = create(AdminCommandSchema);
setExtension(adminCmd, ext, value);
const cmd = create(CommandContainerSchema, { adminCommand: [adminCmd] });
this.sendCommand(cmd, raw => {
if (options) {
handleResponse(ext.typeName, raw, options);
}
});
this.dispatchCommand(ext.typeName, cmd, options);
}
public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void) {
private dispatchCommand<R>(typeName: string, cmd: CommandContainer, options?: CommandOptions<R>): void {
const sent = this.sendCommand(cmd, raw => {
if (options) {
handleResponse(typeName, raw, options);
}
});
if (!sent) {
options?.onError?.(-1, {} as Response);
}
}
public sendCommand(cmd: CommandContainer, callback: (raw: Response) => void): boolean {
if (!this.transport.isOpen()) {
return;
return false;
}
this.cmdId++;
cmd.cmdId = BigInt(this.cmdId);
this.pendingCommands.set(this.cmdId, callback);
this.transport.send(toBinary(CommandContainerSchema, cmd));
return true;
}
public handleMessageEvent({ data }: MessageEvent): void {
@ -153,7 +146,7 @@ export class ProtobufService {
this.processGameEvent(msg.gameEventContainer);
break;
default:
console.log(msg);
console.warn('Unknown message type:', msg);
break;
}
}

View file

@ -198,6 +198,12 @@ describe('WebSocketService', () => {
service.send(data);
expect(mockInstance.send).toHaveBeenCalledWith(data);
});
it('does not throw when socket is undefined (before connect)', () => {
const service = new WebSocketService(mockConfig);
const data = new Uint8Array([1, 2, 3]);
expect(() => service.send(data)).not.toThrow();
});
});
describe('checkReadyState', () => {

View file

@ -54,6 +54,9 @@ export class WebSocketService {
}
public send(message: Uint8Array): void {
if (!this.socket) {
return;
}
this.socket.send(message as unknown as ArrayBufferView);
}