1. Keep session alive with ping-pong. 2. Refreshed tests.

This commit is contained in:
AG
2025-10-16 10:48:11 +03:00
parent 6f64b1daca
commit 95684a34f7
27 changed files with 420 additions and 100 deletions

View File

@@ -0,0 +1,10 @@
{
"files": {
"main.js": "/static/js/main.d2d83152.js",
"index.html": "/index.html",
"main.d2d83152.js.map": "/static/js/main.d2d83152.js.map"
},
"entrypoints": [
"static/js/main.d2d83152.js"
]
}

View File

@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/logo.svg"/><meta name="theme-color" content="#000000"/><meta name="description" content="A real-time app for collaborative decision-making."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><script src="/config.js"></script><title>Unisono</title><script defer="defer" src="/static/js/main.d2d83152.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View File

@@ -0,0 +1,5 @@
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 25 L 90 25" stroke="white" stroke-width="7" stroke-linecap="round"/>
<path d="M10 50 C 40 50, 60 35, 90 25" stroke="white" stroke-width="7" stroke-linecap="round"/>
<path d="M10 75 C 40 75, 60 45, 90 25" stroke="white" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

5
frontend/build/logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 25 L 90 25" stroke="#6750A4" stroke-width="7" stroke-linecap="round"/>
<path d="M10 50 C 40 50, 60 35, 90 25" stroke="#6750A4" stroke-width="7" stroke-linecap="round"/>
<path d="M10 75 C 40 75, 60 45, 90 25" stroke="#6750A4" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 393 B

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-is.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View File

@@ -30,6 +30,11 @@
"eject": "react-scripts eject",
"lint": "eslint src/**/*.{js,jsx,ts,tsx}"
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(axios)/)"
]
},
"eslintConfig": {
"extends": [
"react-app",

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DesireForm from './DesireForm';
@@ -48,13 +48,29 @@ describe('DesireForm', () => {
render(<DesireForm onSubmit={handleSubmit} />);
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => {});
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please enter at least one desire in any category.')).toBeInTheDocument();
});
expect(handleSubmit).not.toHaveBeenCalled();
});
test('shows alert if conflicting desires are entered', async () => {
const handleSubmit = jest.fn();
render(<DesireForm onSubmit={handleSubmit} />);
const wantsInput = screen.getByRole('textbox', { name: /Enter items you want/i });
const acceptsInput = screen.getByRole('textbox', { name: /Enter items you accept/i });
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
await userEvent.type(wantsInput, 'Pizza');
await userEvent.type(acceptsInput, 'Pizza');
fireEvent.click(submitButton);
expect(alertMock).toHaveBeenCalledWith('Please enter at least one desire in any category.');
await waitFor(() => {
expect(screen.getByText('You have conflicting desires (same item in different categories). Please resolve.')).toBeInTheDocument();
});
expect(handleSubmit).not.toHaveBeenCalled();
alertMock.mockRestore();
});
});

View File

@@ -73,7 +73,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
value={wants}
onChange={(e) => setWants(e.target.value)}
margin="normal"
inputProps={{ maxLength: 500 }}
inputProps={{ maxLength: 500, 'aria-label': 'Enter items you want' }}
helperText={`Enter items you want, one per line. Max 500 characters per item. ${wants.length}/500`}
/>
@@ -85,7 +85,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
value={afraidToAsk}
onChange={(e) => setAfraidToAsk(e.target.value)}
margin="normal"
inputProps={{ maxLength: 500 }}
inputProps={{ maxLength: 500, 'aria-label': 'Enter sensitive ideas privately' }}
helperText={`Enter sensitive ideas privately. Max 500 characters. ${afraidToAsk.length}/500`}
/>
@@ -97,7 +97,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
value={accepts}
onChange={(e) => setAccepts(e.target.value)}
margin="normal"
inputProps={{ maxLength: 500 }}
inputProps={{ maxLength: 500, 'aria-label': 'Enter items you accept' }}
helperText={`Enter items you accept, one per line. Max 500 characters per item. ${accepts.length}/500`}
/>
@@ -109,7 +109,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
value={noGoes}
onChange={(e) => setNoGoes(e.target.value)}
margin="normal"
inputProps={{ maxLength: 500 }}
inputProps={{ maxLength: 500, 'aria-label': 'Enter items you absolutely do not want' }}
helperText={`Enter items you absolutely do not want, one per line. Max 500 characters per item. ${noGoes.length}/500`}
/>

View File

@@ -1,22 +1,41 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import CreateSession from './CreateSession';
import axios from 'axios';
test('renders create session page with a form', () => {
render(<CreateSession />);
// Check for a heading
const headingElement = screen.getByText(/Create a New Session/i);
expect(headingElement).toBeInTheDocument();
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
// Check for form fields
const topicInput = screen.getByLabelText(/Topic/i);
expect(topicInput).toBeInTheDocument();
// Mock useNavigate
const mockedUseNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUseNavigate,
}));
const participantsInput = screen.getByLabelText(/Number of Participants/i);
expect(participantsInput).toBeInTheDocument();
describe('CreateSession', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
mockedAxios.post.mockResolvedValue({ data: { sessionId: 'test-session-id' } });
});
// Check for the create button
const createButton = screen.getByRole('button', { name: /Create Session/i });
expect(createButton).toBeInTheDocument();
test('renders loading state initially and then navigates to session page on successful creation', async () => {
render(
<BrowserRouter>
<CreateSession />
</BrowserRouter>
);
// Initially, it should show the loading state
expect(screen.getByText(/Creating a new session.../i)).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// After session creation, it should navigate
await waitFor(() => {
expect(mockedUseNavigate).toHaveBeenCalledWith('/session/test-session-id');
});
});
});

View File

@@ -15,6 +15,7 @@ class WebSocketService {
private sessionTerminatedHandlers: (() => void)[] = [];
private currentSessionId: string | null = null;
private currentClientId: string | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
connect(sessionId: string, clientId: string) {
// Prevent multiple connections
@@ -33,6 +34,13 @@ class WebSocketService {
console.log('WebSocket connected');
// Directly send registration message on open
this.sendMessage({ type: 'REGISTER_CLIENT' });
// Start heartbeat to keep connection alive
this.heartbeatInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'PING', clientId: this.currentClientId, sessionId: this.currentSessionId }));
}
}, 30000); // Send ping every 30 seconds
};
this.ws.onmessage = (event) => {
@@ -47,6 +55,10 @@ class WebSocketService {
this.ws.onclose = () => {
console.log('WebSocket disconnected');
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
this.sessionTerminatedHandlers.forEach(handler => handler());
this.ws = null;
this.currentSessionId = null;
@@ -55,14 +67,26 @@ class WebSocketService {
this.ws.onerror = (event) => {
console.error('WebSocket error:', event);
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
this.errorHandlers.forEach(handler => handler(event));
};
}
disconnect() {
if (this.ws) {
this.ws.close();
}
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
sendMessage(message: any) {

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';