mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
harden
This commit is contained in:
parent
d04aa83258
commit
dcd6dc00f4
83 changed files with 1797 additions and 390 deletions
|
|
@ -1 +1,2 @@
|
|||
# Future template for server admin configuration
|
||||
NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
|
@ -1 +1,4 @@
|
|||
export { withMockLocation, withEventRegistry } from './globalGuards';
|
||||
export { renderWithProviders } from './renderWithProviders';
|
||||
export { createMockWebClient } from './mockWebClient';
|
||||
export { disconnectedState, connectedState, connectedWithRoomsState, makeUser } from './storeFixtures';
|
||||
|
|
|
|||
56
webclient/src/__test-utils__/mockWebClient.ts
Normal file
56
webclient/src/__test-utils__/mockWebClient.ts
Normal 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;
|
||||
}
|
||||
71
webclient/src/__test-utils__/renderWithProviders.tsx
Normal file
71
webclient/src/__test-utils__/renderWithProviders.tsx
Normal 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 }),
|
||||
};
|
||||
}
|
||||
123
webclient/src/__test-utils__/storeFixtures.ts
Normal file
123
webclient/src/__test-utils__/storeFixtures.ts
Normal 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 };
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
33
webclient/src/components/Guard/AuthGuard.spec.tsx
Normal file
33
webclient/src/components/Guard/AuthGuard.spec.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ const AuthGuard = () => {
|
|||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||
return !isConnected
|
||||
? <Navigate to={App.RouteEnum.LOGIN} />
|
||||
: <div></div>;
|
||||
: <></>;
|
||||
};
|
||||
|
||||
export default AuthGuard;
|
||||
|
|
|
|||
38
webclient/src/components/Guard/ModGuard.spec.tsx
Normal file
38
webclient/src/components/Guard/ModGuard.spec.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
30
webclient/src/components/InputField/InputField.spec.tsx
Normal file
30
webclient/src/components/InputField/InputField.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
19
webclient/src/components/Message/Message.spec.tsx
Normal file
19
webclient/src/components/Message/Message.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
46
webclient/src/components/UserDisplay/UserDisplay.spec.tsx
Normal file
46
webclient/src/components/UserDisplay/UserDisplay.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
21
webclient/src/components/VirtualList/VirtualList.spec.tsx
Normal file
21
webclient/src/components/VirtualList/VirtualList.spec.tsx
Normal 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]');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
35
webclient/src/containers/Room/OpenGames.spec.tsx
Normal file
35
webclient/src/containers/Room/OpenGames.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)) }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)) }
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginForm
|
|||
|
||||
const handleOnSubmit = ({ userName, ...values }) => {
|
||||
userName = userName?.trim();
|
||||
console.log(userName, values);
|
||||
|
||||
onSubmit({ userName, ...values });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
export * from './useAutoConnect';
|
||||
export * from './useFireOnce';
|
||||
export * from './useDebounce';
|
||||
export * from './useLocaleSort';
|
||||
export * from './useReduxEffect';
|
||||
export * from './useWebClient';
|
||||
|
|
|
|||
86
webclient/src/hooks/useAutoConnect.spec.ts
Normal file
86
webclient/src/hooks/useAutoConnect.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
80
webclient/src/hooks/useLocaleSort.spec.ts
Normal file
80
webclient/src/hooks/useLocaleSort.spec.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
138
webclient/src/hooks/useReduxEffect.spec.tsx
Normal file
138
webclient/src/hooks/useReduxEffect.spec.tsx
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
48
webclient/src/hooks/useWebClient.spec.tsx
Normal file
48
webclient/src/hooks/useWebClient.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
261
webclient/src/services/CardImporterService.spec.ts
Normal file
261
webclient/src/services/CardImporterService.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
47
webclient/src/services/HostService.ts
Normal file
47
webclient/src/services/HostService.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './CardImporterService';
|
||||
export * from './HostService';
|
||||
export * from './ServerProps';
|
||||
export * from './dexie';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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()', () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export interface ServerState {
|
|||
export interface ServerStateStatus {
|
||||
connectionAttemptMade: boolean;
|
||||
description: string | null;
|
||||
state: App.StatusEnum;
|
||||
state: Enriched.StatusEnum;
|
||||
}
|
||||
|
||||
export interface ServerStateInfo {
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 }>) => {},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './cards';
|
||||
export * from './constants';
|
||||
export * from './regex-patterns';
|
||||
export * from './countries';
|
||||
export * from './languages';
|
||||
export * from './routes';
|
||||
|
|
|
|||
|
|
@ -250,4 +250,6 @@ export const countryCodes = [
|
|||
'XK',
|
||||
'ZM',
|
||||
'ZW',
|
||||
];
|
||||
] as const;
|
||||
|
||||
export type CountryCode = typeof countryCodes[number];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import {
|
|||
URL_REGEX,
|
||||
MESSAGE_SENDER_REGEX,
|
||||
MENTION_REGEX,
|
||||
} from './constants';
|
||||
} from './regex-patterns';
|
||||
|
||||
describe('RegEx', () => {
|
||||
describe('URL_REGEX', () => {
|
||||
|
|
@ -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' },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ export class WebSocketService {
|
|||
}
|
||||
|
||||
public send(message: Uint8Array): void {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
this.socket.send(message as unknown as ArrayBufferView);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue