Merge branch 'review/slice-8-session'
ci / server (push) Failing after 3m36s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-20 11:11:54 +03:00
7 changed files with 219 additions and 6 deletions
@@ -0,0 +1,74 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchSchools, type SchoolsResponse } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { MainMenu } from './mainMenu.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchSchools: vi.fn(),
};
});
describe('MainMenu ownership', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
});
it('hides delete on another owners card and shows it on an ownerless one', async () => {
vi.mocked(fetchSchools).mockResolvedValue(menuState());
const menu = new MainMenu({
onOpenSchool: () => {},
onLogout: () => {},
});
document.body.append(menu.element);
await menu.refresh();
const foreign = menu.element.querySelector('[data-school-id="2"]');
const orphan = menu.element.querySelector('[data-school-id="3"]');
expect((foreign?.querySelector('.button--danger') as HTMLElement).hidden).toBe(true);
expect((orphan?.querySelector('.button--danger') as HTMLElement).hidden).toBe(false);
expect(foreign?.textContent).toContain('Bob');
expect(orphan?.textContent).toContain(t('schoolOwnerless'));
});
});
function menuState(): SchoolsResponse {
return {
maxSchools: 2,
maxSchoolsTotal: 16,
defaultStartDate: '2012-03-31T06:00:00.000Z',
gameMinutesPerRealSecond: 1,
schoolWeekDays: 5,
schools: [],
others: [
{
id: 2,
name: 'Foreign',
gameTime: '2012-03-31T06:00:00.000Z',
running: true,
speedIndex: 1,
seed: 1,
owner: 'Bob',
},
{
id: 3,
name: 'Orphan',
gameTime: '2012-03-31T06:00:00.000Z',
running: true,
speedIndex: 1,
seed: 2,
owner: null,
},
],
};
}
+30 -3
View File
@@ -7,6 +7,7 @@ namespace HSchool.Server.Net;
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
private readonly object _names = new();
private uint _nextPlayerId;
public int Count => _clients.Count;
@@ -28,14 +29,40 @@ internal sealed class ClientRegistry
public bool IsUserNameOnline(string normalizedUserName)
{
foreach (var client in _clients.Values)
lock (_names)
{
if (client.UserName is null)
return HasUserName(normalizedUserName);
}
}
/// <summary>
/// One live socket per name. Checked under the same lock as <see cref="IsUserNameOnline"/>
/// so a second tab cannot sneak a Welcome in between the HTTP check and SetUserName.
/// </summary>
public bool TryClaimUserName(GameClient client, string userName)
{
lock (_names)
{
if (HasUserName(userName))
{
return false;
}
client.SetUserName(userName);
return true;
}
}
private bool HasUserName(string normalizedUserName)
{
foreach (var existing in _clients.Values)
{
if (existing.UserName is null)
{
continue;
}
if (string.Equals(client.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
if (string.Equals(existing.NormalizedUserName, normalizedUserName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
+10 -1
View File
@@ -23,7 +23,6 @@ internal sealed class GameSocketHandler(
public async Task HandleAsync(WebSocket socket, string userName, CancellationToken cancellationToken)
{
var client = clients.Add(socket);
client.SetUserName(userName);
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -32,6 +31,16 @@ internal sealed class GameSocketHandler(
try
{
if (!clients.TryClaimUserName(client, userName))
{
await CloseAsync(
socket,
WebSocketCloseStatus.PolicyViolation,
"Name is already online.",
cancellationToken).ConfigureAwait(false);
return;
}
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
handshakeCts.CancelAfter(HandshakeTimeout);