A Tests/NosSmooth.Game.Tests/Helpers/XUnitLogger.cs => Tests/NosSmooth.Game.Tests/Helpers/XUnitLogger.cs +157 -0
@@ 0,0 1,157 @@
+//
+// XUnitLogger.cs
+//
+// Copyright (c) František Boháček. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Xunit.Abstractions;
+
+namespace NosSmooth.Game.Tests.Helpers;
+
+/// <summary>
+/// X unit logger.
+/// </summary>
+internal class XUnitLogger : ILogger
+{
+ private readonly ITestOutputHelper _testOutputHelper;
+ private readonly string _categoryName;
+ private readonly LoggerExternalScopeProvider _scopeProvider;
+
+ /// <summary>
+ /// Creates a logger for the given test output.
+ /// </summary>
+ /// <param name="testOutputHelper">The test output helper.</param>
+ /// <returns>A logger.</returns>
+ public static ILogger CreateLogger(ITestOutputHelper testOutputHelper)
+ => new XUnitLogger(testOutputHelper, new LoggerExternalScopeProvider(), string.Empty);
+
+ /// <summary>
+ /// Creates a logger for the given test output.
+ /// </summary>
+ /// <param name="testOutputHelper">The test output helper.</param>
+ /// <typeparam name="T">The type to create logger for.</typeparam>
+ /// <returns>A logger.</returns>
+ public static ILogger<T> CreateLogger<T>(ITestOutputHelper testOutputHelper)
+ => new XUnitLogger<T>(testOutputHelper, new LoggerExternalScopeProvider());
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="XUnitLogger"/> class.
+ /// </summary>
+ /// <param name="testOutputHelper">The test output helper.</param>
+ /// <param name="scopeProvider">The scope provider.</param>
+ /// <param name="categoryName">The category name.</param>
+ public XUnitLogger
+ (ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider, string categoryName)
+ {
+ _testOutputHelper = testOutputHelper;
+ _scopeProvider = scopeProvider;
+ _categoryName = categoryName;
+ }
+
+ /// <inheritdoc/>
+ public bool IsEnabled(LogLevel logLevel)
+ => logLevel != LogLevel.None;
+
+ /// <inheritdoc/>
+ public IDisposable? BeginScope<TState>(TState state)
+ where TState : notnull
+ {
+ return _scopeProvider.Push(state);
+ }
+
+ /// <inheritdoc/>
+ public void Log<TState>
+ (
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func<TState, Exception?, string> formatter
+ )
+ {
+ var sb = new StringBuilder();
+ sb.Append(GetLogLevelString(logLevel))
+ .Append(" [").Append(_categoryName).Append("] ")
+ .Append(formatter(state, exception));
+
+ if (exception != null)
+ {
+ sb.Append('\n').Append(exception);
+ }
+
+ // Append scopes
+ _scopeProvider.ForEachScope
+ (
+ (scope, state) =>
+ {
+ state.Append("\n => ");
+ state.Append(scope);
+ },
+ sb
+ );
+
+ _testOutputHelper.WriteLine(sb.ToString());
+ }
+
+ private static string GetLogLevelString(LogLevel logLevel)
+ {
+ return logLevel switch
+ {
+ LogLevel.Trace => "trce",
+ LogLevel.Debug => "dbug",
+ LogLevel.Information => "info",
+ LogLevel.Warning => "warn",
+ LogLevel.Error => "fail",
+ LogLevel.Critical => "crit",
+ _ => throw new ArgumentOutOfRangeException(nameof(logLevel))
+ };
+ }
+}
+
+/// <summary>
+/// A xunit logger for specific type.
+/// </summary>
+/// <typeparam name="T">The type the xunit logger is for.</typeparam>
+internal sealed class XUnitLogger<T> : XUnitLogger, ILogger<T>
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="XUnitLogger{T}"/> class.
+ /// </summary>
+ /// <param name="testOutputHelper">The test output helper.</param>
+ /// <param name="scopeProvider">The scope provider.</param>
+ public XUnitLogger(ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider)
+ : base(testOutputHelper, scopeProvider, typeof(T).FullName!)
+ {
+ }
+}
+
+/// <summary>
+/// A <see cref="XUnitLogger"/> provider.
+/// </summary>
+internal sealed class XUnitLoggerProvider : ILoggerProvider
+{
+ private readonly ITestOutputHelper _testOutputHelper;
+ private readonly LoggerExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider();
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="XUnitLoggerProvider"/> class.
+ /// </summary>
+ /// <param name="testOutputHelper">The test output helper.</param>
+ public XUnitLoggerProvider(ITestOutputHelper testOutputHelper)
+ {
+ _testOutputHelper = testOutputHelper;
+ }
+
+ /// <inheritdoc/>
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new XUnitLogger(_testOutputHelper, _scopeProvider, categoryName);
+ }
+
+ /// <inheritdoc/>
+ public void Dispose()
+ {
+ }
+}<
\ No newline at end of file
A Tests/NosSmooth.Game.Tests/NosSmooth.Game.Tests.csproj => Tests/NosSmooth.Game.Tests/NosSmooth.Game.Tests.csproj +44 -0
@@ 0,0 1,44 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+
+ <IsPackable>false</IsPackable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
+ <PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
+ <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="7.0.0" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
+ <PackageReference Include="Shouldly" Version="4.1.0" />
+ <PackageReference Include="xunit" Version="2.4.1" />
+ <PackageReference Include="xunit.analyzers" Version="1.1.0" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="coverlet.collector" Version="3.1.2">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\Core\NosSmooth.Core\NosSmooth.Core.csproj" />
+ <ProjectReference Include="..\..\Core\NosSmooth.Game\NosSmooth.Game.csproj" />
+ <ProjectReference Include="..\..\Data\NosSmooth.Data.NOSFiles\NosSmooth.Data.NOSFiles.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Update="Packets\Modules\Mates\Inferno_No_Partner.log">
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ <None Update="Packets\Modules\Mates\Otter_and_Graham_Yuna.log">
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+</Project>
A Tests/NosSmooth.Game.Tests/PacketFileClient.cs => Tests/NosSmooth.Game.Tests/PacketFileClient.cs +302 -0
@@ 0,0 1,302 @@
+//
+// PacketFileClient.cs
+//
+// Copyright (c) František Boháček. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NosSmooth.Core.Client;
+using NosSmooth.Core.Commands;
+using NosSmooth.Core.Extensions;
+using NosSmooth.Core.Packets;
+using NosSmooth.Data.NOSFiles;
+using NosSmooth.Data.NOSFiles.Extensions;
+using NosSmooth.Game.Extensions;
+using NosSmooth.Game.Tests.Helpers;
+using NosSmooth.Packets;
+using NosSmooth.PacketSerializer;
+using NosSmooth.PacketSerializer.Abstractions.Attributes;
+using NosSmooth.PacketSerializer.Errors;
+using NosSmooth.PacketSerializer.Extensions;
+using NosSmooth.PacketSerializer.Packets;
+using Remora.Results;
+using Xunit.Abstractions;
+
+namespace NosSmooth.Game.Tests;
+
+/// <summary>
+/// A client used for tests. Supports loading just part of a file with packets.
+/// </summary>
+public class PacketFileClient : BaseNostaleClient, IDisposable
+{
+ private const string LineRegex = ".*\\[(Recv|Send)\\]\t(.*)";
+ private const string LabelRegex = "##(.*)";
+
+ // TODO: make this class cleaner
+
+ private readonly FileStream _stream;
+ private readonly StreamReader _reader;
+ private readonly IPacketSerializer _packetSerializer;
+ private readonly PacketHandler _packetHandler;
+ private readonly ILogger<PacketFileClient> _logger;
+ private string? _nextLabel;
+ private bool _skip;
+ private bool _readToLabel;
+
+ /// <summary>
+ /// Builds a file client for the given test.
+ /// </summary>
+ /// <param name="testName">The name of the test.</param>
+ /// <param name="testOutputHelper">The output helper to output logs to.</param>
+ /// <typeparam name="TTest">The test type.</typeparam>
+ /// <returns>A file client and the associated game.</returns>
+ public static (PacketFileClient Client, Game Game) CreateFor<TTest>(string testName, ITestOutputHelper testOutputHelper)
+ {
+ var services = new ServiceCollection()
+ .AddLogging(b => b.AddProvider(new XUnitLoggerProvider(testOutputHelper)))
+ .AddNostaleCore()
+ .AddNostaleGame()
+ .AddSingleton<PacketFileClient>(p => CreateFor<TTest>(p, testName))
+ .AddSingleton<INostaleClient>(p => p.GetRequiredService<PacketFileClient>())
+ .AddNostaleDataFiles()
+ .BuildServiceProvider();
+
+ services.GetRequiredService<IPacketTypesRepository>().AddDefaultPackets();
+ if (!services.GetRequiredService<NostaleDataFilesManager>().Initialize().IsSuccess)
+ {
+ throw new Exception("Data not initialized correctly.");
+ }
+
+ return (services.GetRequiredService<PacketFileClient>(), services.GetRequiredService<Game>());
+ }
+
+ /// <summary>
+ /// Create a file client for the given test.
+ /// </summary>
+ /// <param name="services">The services provider.</param>
+ /// <param name="testName">The name of the test.</param>
+ /// <typeparam name="TTest">The test class.</typeparam>
+ /// <returns>A client.</returns>
+ public static PacketFileClient CreateFor<TTest>(IServiceProvider services, string testName)
+ {
+ var prefix = "NosSmooth.Game.Tests.";
+ var name = typeof(TTest).FullName!.Substring(prefix.Length).Replace("Tests", string.Empty);
+
+ var splitted = name.Split('.');
+ var path = "Packets/";
+
+ foreach (var entry in splitted)
+ {
+ path += entry + "/";
+ }
+
+ path += testName + ".log";
+
+ return Create
+ (
+ services,
+ path
+ );
+ }
+
+ /// <summary>
+ /// Create an instance of PacketFileClient for the given file.
+ /// </summary>
+ /// <param name="services">The services provider.</param>
+ /// <param name="fileName">The file name.</param>
+ /// <returns>A client.</returns>
+ public static PacketFileClient Create(IServiceProvider services, string fileName)
+ {
+ return (PacketFileClient)ActivatorUtilities.CreateInstance
+ (services, typeof(PacketFileClient), new[] { File.OpenRead(fileName) });
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PacketFileClient"/> class.
+ /// </summary>
+ /// <param name="stream">The file stream.</param>
+ /// <param name="packetSerializer">The packet serializer.</param>
+ /// <param name="commandProcessor">The command processor.</param>
+ /// <param name="packetHandler">The packet handler.</param>
+ /// <param name="logger">The logger.</param>
+ public PacketFileClient
+ (
+ FileStream stream,
+ IPacketSerializer packetSerializer,
+ CommandProcessor commandProcessor,
+ PacketHandler packetHandler,
+ ILogger<PacketFileClient> logger
+ )
+ : base(commandProcessor, packetSerializer)
+ {
+ _stream = stream;
+ _reader = new StreamReader(_stream);
+ _packetSerializer = packetSerializer;
+ _packetHandler = packetHandler;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Start executing until the given label is hit.
+ /// </summary>
+ /// <param name="label">The label to hit.</param>
+ /// <returns>An asynchronous operation.</returns>
+ public async Task ExecuteUntilLabelAsync(string label)
+ {
+ _readToLabel = false;
+ _nextLabel = label;
+ await RunAsync();
+
+ if (!_readToLabel)
+ {
+ throw new Exception($"Label {label} not found.");
+ }
+ }
+
+ /// <summary>
+ /// Start executing until the end of the file.
+ /// </summary>
+ /// <returns>An asynchronous operation.</returns>
+ public Task ExecuteToEnd()
+ {
+ _nextLabel = null;
+ return RunAsync();
+ }
+
+ /// <summary>
+ /// Skip cursor until the given label is hit.
+ /// </summary>
+ /// <param name="label">The label to hit.</param>
+ /// <returns>An asynchronous operation.</returns>
+ public async Task SkipUntilLabelAsync(string label)
+ {
+ try
+ {
+ _readToLabel = false;
+ _nextLabel = label;
+ _skip = true;
+ await RunAsync();
+ }
+ finally
+ {
+ _skip = false;
+ }
+
+ if (!_readToLabel)
+ {
+ throw new Exception($"Label {label} not found.");
+ }
+ }
+
+ /// <inheritdoc />
+ public override async Task<Result> RunAsync(CancellationToken stopRequested = default)
+ {
+ var packetRegex = new Regex(LineRegex);
+ var labelRegex = new Regex(LabelRegex);
+ while (!_reader.EndOfStream)
+ {
+ stopRequested.ThrowIfCancellationRequested();
+ var line = await _reader.ReadLineAsync(stopRequested);
+ if (string.IsNullOrEmpty(line))
+ {
+ continue;
+ }
+
+ var labelMatch = labelRegex.Match(line);
+ if (labelMatch.Success)
+ {
+ var label = labelMatch.Groups[1].Value;
+ if (label == _nextLabel)
+ {
+ _readToLabel = true;
+ break;
+ }
+
+ continue;
+ }
+
+ if (_skip)
+ {
+ continue;
+ }
+
+ var packetMatch = packetRegex.Match(line);
+ if (!packetMatch.Success)
+ {
+ _logger.LogWarning($"Could not find match on line {line}");
+ continue;
+ }
+
+ var type = packetMatch.Groups[1].Value;
+ var packetStr = packetMatch.Groups[2].Value;
+
+ var source = type == "Recv" ? PacketSource.Server : PacketSource.Client;
+ var packet = CreatePacket(packetStr, source);
+ Result result = await _packetHandler.HandlePacketAsync
+ (
+ this,
+ source,
+ packet,
+ packetStr,
+ stopRequested
+ );
+ if (!result.IsSuccess)
+ {
+ _logger.LogResultError(result);
+ }
+ }
+
+ return Result.FromSuccess();
+ }
+
+ /// <inheritdoc/>
+ public override Task<Result> SendPacketAsync(string packetString, CancellationToken ct = default)
+ {
+ return _packetHandler.HandlePacketAsync
+ (
+ this,
+ PacketSource.Client,
+ CreatePacket(packetString, PacketSource.Client),
+ packetString,
+ ct
+ );
+ }
+
+ /// <inheritdoc/>
+ public override Task<Result> ReceivePacketAsync(string packetString, CancellationToken ct = default)
+ {
+ return _packetHandler.HandlePacketAsync
+ (
+ this,
+ PacketSource.Server,
+ CreatePacket(packetString, PacketSource.Server),
+ packetString,
+ ct
+ );
+ }
+
+ private IPacket CreatePacket(string packetStr, PacketSource source)
+ {
+ var packetResult = _packetSerializer.Deserialize(packetStr, source);
+ if (!packetResult.IsSuccess)
+ {
+ if (packetResult.Error is PacketConverterNotFoundError err)
+ {
+ return new UnresolvedPacket(err.Header, packetStr);
+ }
+
+ return new ParsingFailedPacket(packetResult, packetStr);
+ }
+
+ return packetResult.Entity;
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ _stream.Dispose();
+ _reader.Dispose();
+ }
+}<
\ No newline at end of file
A Tests/NosSmooth.Game.Tests/Usings.cs => Tests/NosSmooth.Game.Tests/Usings.cs +7 -0
@@ 0,0 1,7 @@
+//
+// Usings.cs
+//
+// Copyright (c) František Boháček. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+global using Xunit;<
\ No newline at end of file