Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions MyChat/MyChat.Tests/ArgumentsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// <copyright file="ArgumentsTests.cs" company="khusainovilas">
// Copyright (c) khusainovilas. All rights reserved.
// </copyright>

namespace MyChat.Test;

using MyChat;
using NUnit.Framework;

/// <summary>
/// Unit tests for <see cref="Arguments"/> parsing logic.
/// </summary>
public class ArgumentsTests
{
/// <summary>
/// Tests that parsing a single argument returns server mode.
/// </summary>
[Test]
public void Arguments_Parse_SingleArgument_ReturnsServerMode()
{
var args = new[] { "5000" };
var parsed = Arguments.Parse(args);

Assert.Multiple(() =>
{
Assert.That(parsed.IsServer, Is.True);
Assert.That(parsed.Port, Is.EqualTo(5000));
Assert.That(parsed.IpAddress, Is.Null);
});
}

/// <summary>
/// Tests that parsing two arguments returns client mode.
/// </summary>
[Test]
public void Arguments_Parse_TwoArguments_ReturnsClientMode()
{
var args = new[] { "127.0.0.1", "5000" };
var parsed = Arguments.Parse(args);

Assert.Multiple(() =>
{
Assert.That(parsed.IsServer, Is.False);
Assert.That(parsed.Port, Is.EqualTo(5000));
Assert.That(parsed.IpAddress, Is.EqualTo("127.0.0.1"));
});
}
}
73 changes: 73 additions & 0 deletions MyChat/MyChat.Tests/ChatRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// <copyright file="ChatRunnerTests.cs" company="khusainovilas">
// Copyright (c) khusainovilas. All rights reserved.
// </copyright>

namespace MyChat.Test;

using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using MyChat;
using NUnit.Framework;

/// <summary>
/// Tests for <see cref="ChatRunner"/>.
/// </summary>
[TestFixture]
public class ChatRunnerTests
{
/// <summary>
/// Tests that a server and client can exchange a single message.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Test]
public async Task ChatRunner_RunAsync_ServerReceive()
{
const string ipAddress = "127.0.0.1";
const int port = 5005;

string serverReceived = string.Empty;
string clientMessage = "Hello!";

var serverTask = Task.Run(async () =>
{
var listener = new TcpListener(IPAddress.Parse(ipAddress), port);
listener.Start();

using var client = await listener.AcceptTcpClientAsync();
using var stream = client.GetStream();
using var reader = new StreamReader(stream);
using var writer = new StreamWriter(stream) { AutoFlush = true };

serverReceived = await reader.ReadLineAsync() ?? string.Empty;
await writer.WriteLineAsync(serverReceived);

listener.Stop();
});

await Task.Delay(200);

var clientTask = Task.Run(async () =>
{
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Parse(ipAddress), port);
using var stream = client.GetStream();
using var reader = new StreamReader(stream);
using var writer = new StreamWriter(stream) { AutoFlush = true };

await writer.WriteLineAsync(clientMessage);
var received = await reader.ReadLineAsync()!;
return received;
});

var clientReceived = await clientTask;
await serverTask;

Assert.Multiple(() =>
{
Assert.That(serverReceived, Is.EqualTo(clientMessage));
Assert.That(clientReceived, Is.EqualTo(clientMessage));
});
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Удивительно, но клиент и сервер в тесте даже не участвуют, тестируются TcpListener и TcpClient из стандартной библиотеки

}
39 changes: 39 additions & 0 deletions MyChat/MyChat.Tests/MyChat.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="NUnit" Version="4.2.2"/>
<PackageReference Include="NUnit.Analyzers" Version="4.4.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0"/>
</ItemGroup>

<ItemGroup>
<Using Include="NUnit.Framework"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<AdditionalFiles Include="stylecop.json" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\MyChat\MyChat.csproj" />
</ItemGroup>

</Project>
9 changes: 9 additions & 0 deletions MyChat/MyChat.Tests/stylecop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
"settings": {
"documentationRules": {
"companyName": "khusainovilas",
"copyrightText": "Copyright (c) {companyName}. All rights reserved."
}
}
}
22 changes: 22 additions & 0 deletions MyChat/MyChat.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyChat", "MyChat\MyChat.csproj", "{DE4B1FDD-7626-40A3-B323-CE8B371EA5E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyChat.Tests", "MyChat.Tests\MyChat.Tests.csproj", "{A801DB41-1484-46AA-9D09-0CFEB18C7271}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DE4B1FDD-7626-40A3-B323-CE8B371EA5E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DE4B1FDD-7626-40A3-B323-CE8B371EA5E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE4B1FDD-7626-40A3-B323-CE8B371EA5E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE4B1FDD-7626-40A3-B323-CE8B371EA5E1}.Release|Any CPU.Build.0 = Release|Any CPU
{A801DB41-1484-46AA-9D09-0CFEB18C7271}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A801DB41-1484-46AA-9D09-0CFEB18C7271}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A801DB41-1484-46AA-9D09-0CFEB18C7271}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A801DB41-1484-46AA-9D09-0CFEB18C7271}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
54 changes: 54 additions & 0 deletions MyChat/MyChat/Arguments.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// <copyright file="Arguments.cs" company="khusainovilas">
// Copyright (c) khusainovilas. All rights reserved.
// </copyright>

namespace MyChat;

using System;

/// <summary>
/// Represents parsed command-line arguments for the chat application.
/// </summary>
public class Arguments

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Думаю, что можно было сделать эту штуку record-ом или хотя бы сделать основной конструктор

{
private Arguments(int port, string? ipAddress)
{
this.Port = port;
this.IpAddress = ipAddress;
}

/// <summary>
/// Gets the network port number.
/// </summary>
public int Port { get; }

/// <summary>
/// Gets the IP address for client mode.
/// </summary>
public string? IpAddress { get; }

/// <summary>
/// Gets a value indicating whether returns the value, is it a server or an application.
/// </summary>
public bool IsServer => this.IpAddress is null;

/// <summary>
/// Parses command-line arguments.
/// </summary>
/// <param name="args">Command-line arguments.</param>
/// <returns>Parsed <see cref="Arguments"/> instance.</returns>
public static Arguments Parse(string[] args)
{
if (args.Length == 1)
{
return new Arguments(int.Parse(args[0]), null);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Стоило бы заодно проверить, принадлежит ли номер порта разрешённому диапазону, чтобы выдать пользователю понятную диагностику.

}

if (args.Length == 2)
{
return new Arguments(int.Parse(args[1]), args[0]);
}

throw new ArgumentException("Invalid number of command-line arguments.");
}
}
133 changes: 133 additions & 0 deletions MyChat/MyChat/ChatRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// <copyright file="ChatRunner.cs" company="khusainovilas">
// Copyright (c) khusainovilas. All rights reserved.
// </copyright>

namespace MyChat;

using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;

/// <summary>
/// Provides methods for running a chat application in server or client mode.
/// </summary>
public static class ChatRunner
{
private static readonly List<TcpClient> ConnectedClients = [];

/// <summary>
/// Starts the chat application based on the provided arguments.
/// </summary>
/// <param name="arguments">Arguments containing port and optional IP address.</param>
/// <returns>A <see cref="Task"/> representing the async operation.</returns>
public static async Task RunAsync(Arguments arguments)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Очеь желательно всё, что Async, делать отменяемым (принимать CancellationToken опциональным параметром, чтобы кому надо — могли операцию отменить). Тут и в методах ниже.

{
if (arguments.IsServer)
{
await RunServerAsync(arguments.Port);
}
else
{
await RunClientAsync(arguments.IpAddress!, arguments.Port);
}
}

/// <summary>
/// Runs the chat application as a server listening.
/// </summary>
/// <param name="port">Port number to listen on.</param>
/// <returns>Representing the asynchronous server operation.</returns>
public static async Task RunServerAsync(int port)
{
var listener = new TcpListener(IPAddress.Any, port);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TcpListener IDisposable, поэтому лучше его объявлять с using. Иначе из-за какого-нибудь исключения listener.Stop может не исполниться и порт останется занят до конца работы программы.

listener.Start();
Console.WriteLine($"Server listening on port {port}...");

while (true)
{
var client = await listener.AcceptTcpClientAsync();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Оставлять потенциально бесконечное по времени действие неотменяемым очень страшно, тут бы CancellationToken передавать

lock (ConnectedClients)
{
ConnectedClients.Add(client);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

По условию клиент всего один, так что это зря :)

}

var endpoint = client.Client.RemoteEndPoint?.ToString() ?? "Unknown";

Console.WriteLine($"Client connected: {endpoint}");

_ = Task.Run(async () =>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А так Вы теряете ссылку на запущенную задачу, следовательно не можете её ни прервать, ни дождаться её завершения

{
await RunChatAsync(client, endpoint);

Console.WriteLine($"Client disconnected: {endpoint}");
lock (ConnectedClients)
{
ConnectedClients.Remove(client);
}

lock (ConnectedClients)
{
if (ConnectedClients.Count == 0)
{
Console.WriteLine("No clients connected. Server shutting down.");
Environment.Exit(0);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Это вообще не стоит использовать — вдруг сервер захотят переиспользовать в большем приложении

}
}
});
}
}

/// <summary>
/// Runs the chat application as a client.
/// </summary>
/// <param name="ip">IP address of the server to connect to.</param>
/// <param name="port">Port number of the server.</param>
/// <returns>Representing the async client operation.</returns>
public static async Task RunClientAsync(string ip, int port)
{
using var client = new TcpClient();
await client.ConnectAsync(ip, port);
Console.WriteLine($"Connected to server {ip}:{port}");
await RunChatAsync(client, "Server");
}

private static async Task RunChatAsync(TcpClient client, string name)
{
using var stream = client.GetStream();
using var reader = new StreamReader(stream);
using var writer = new StreamWriter(stream) { AutoFlush = true };

var consoleTask = Task.Run(async () =>
{
while (true)
{
var line = Console.ReadLine();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Это тоже надо было бы сделать отменяемым. Иначе клиент уже отключился, а мы сидим тут и ждём, пока пользователь что-то введёт, хотя уже давно должны были закрыть программу. У Вас эта проблема решается с помощью Environment.Exit, но это всё равно что выключатель света в доме сломался, мы дом снесли к чертям.

if (line == null || line.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}

await writer.WriteLineAsync(line);
}
});

var networkTask = Task.Run(async () =>
{
while (true)
{
var line = await reader.ReadLineAsync();
if (line == null)
{
break;
}

Console.WriteLine($"> {line}");
}
});

await Task.WhenAny(consoleTask, networkTask);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

По-хорошему надо корректно остановить и дождаться завершения обеих задач

client.Close();
}
}
Loading