From c610aeff9689feebc00cd6262b9c4c123287318d Mon Sep 17 00:00:00 2001 From: Rutherther Date: Tue, 10 Jan 2023 22:30:45 +0100 Subject: [PATCH] tests(game): add support for game integration tests --- .../Helpers/XUnitLogger.cs | 157 +++++++++ .../NosSmooth.Game.Tests.csproj | 44 +++ .../NosSmooth.Game.Tests/PacketFileClient.cs | 302 ++++++++++++++++++ Tests/NosSmooth.Game.Tests/Usings.cs | 7 + 4 files changed, 510 insertions(+) create mode 100644 Tests/NosSmooth.Game.Tests/Helpers/XUnitLogger.cs create mode 100644 Tests/NosSmooth.Game.Tests/NosSmooth.Game.Tests.csproj create mode 100644 Tests/NosSmooth.Game.Tests/PacketFileClient.cs create mode 100644 Tests/NosSmooth.Game.Tests/Usings.cs diff --git a/Tests/NosSmooth.Game.Tests/Helpers/XUnitLogger.cs b/Tests/NosSmooth.Game.Tests/Helpers/XUnitLogger.cs new file mode 100644 index 0000000..d718187 --- /dev/null +++ b/Tests/NosSmooth.Game.Tests/Helpers/XUnitLogger.cs @@ -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; + +/// +/// X unit logger. +/// +internal class XUnitLogger : ILogger +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly string _categoryName; + private readonly LoggerExternalScopeProvider _scopeProvider; + + /// + /// Creates a logger for the given test output. + /// + /// The test output helper. + /// A logger. + public static ILogger CreateLogger(ITestOutputHelper testOutputHelper) + => new XUnitLogger(testOutputHelper, new LoggerExternalScopeProvider(), string.Empty); + + /// + /// Creates a logger for the given test output. + /// + /// The test output helper. + /// The type to create logger for. + /// A logger. + public static ILogger CreateLogger(ITestOutputHelper testOutputHelper) + => new XUnitLogger(testOutputHelper, new LoggerExternalScopeProvider()); + + /// + /// Initializes a new instance of the class. + /// + /// The test output helper. + /// The scope provider. + /// The category name. + public XUnitLogger + (ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider, string categoryName) + { + _testOutputHelper = testOutputHelper; + _scopeProvider = scopeProvider; + _categoryName = categoryName; + } + + /// + public bool IsEnabled(LogLevel logLevel) + => logLevel != LogLevel.None; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return _scopeProvider.Push(state); + } + + /// + public void Log + ( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func 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)) + }; + } +} + +/// +/// A xunit logger for specific type. +/// +/// The type the xunit logger is for. +internal sealed class XUnitLogger : XUnitLogger, ILogger +{ + /// + /// Initializes a new instance of the class. + /// + /// The test output helper. + /// The scope provider. + public XUnitLogger(ITestOutputHelper testOutputHelper, LoggerExternalScopeProvider scopeProvider) + : base(testOutputHelper, scopeProvider, typeof(T).FullName!) + { + } +} + +/// +/// A provider. +/// +internal sealed class XUnitLoggerProvider : ILoggerProvider +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly LoggerExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider(); + + /// + /// Initializes a new instance of the class. + /// + /// The test output helper. + public XUnitLoggerProvider(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + /// + public ILogger CreateLogger(string categoryName) + { + return new XUnitLogger(_testOutputHelper, _scopeProvider, categoryName); + } + + /// + public void Dispose() + { + } +} \ No newline at end of file diff --git a/Tests/NosSmooth.Game.Tests/NosSmooth.Game.Tests.csproj b/Tests/NosSmooth.Game.Tests/NosSmooth.Game.Tests.csproj new file mode 100644 index 0000000..75a4917 --- /dev/null +++ b/Tests/NosSmooth.Game.Tests/NosSmooth.Game.Tests.csproj @@ -0,0 +1,44 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + Always + + + Always + + + + diff --git a/Tests/NosSmooth.Game.Tests/PacketFileClient.cs b/Tests/NosSmooth.Game.Tests/PacketFileClient.cs new file mode 100644 index 0000000..0b5bc81 --- /dev/null +++ b/Tests/NosSmooth.Game.Tests/PacketFileClient.cs @@ -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; + +/// +/// A client used for tests. Supports loading just part of a file with packets. +/// +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 _logger; + private string? _nextLabel; + private bool _skip; + private bool _readToLabel; + + /// + /// Builds a file client for the given test. + /// + /// The name of the test. + /// The output helper to output logs to. + /// The test type. + /// A file client and the associated game. + public static (PacketFileClient Client, Game Game) CreateFor(string testName, ITestOutputHelper testOutputHelper) + { + var services = new ServiceCollection() + .AddLogging(b => b.AddProvider(new XUnitLoggerProvider(testOutputHelper))) + .AddNostaleCore() + .AddNostaleGame() + .AddSingleton(p => CreateFor(p, testName)) + .AddSingleton(p => p.GetRequiredService()) + .AddNostaleDataFiles() + .BuildServiceProvider(); + + services.GetRequiredService().AddDefaultPackets(); + if (!services.GetRequiredService().Initialize().IsSuccess) + { + throw new Exception("Data not initialized correctly."); + } + + return (services.GetRequiredService(), services.GetRequiredService()); + } + + /// + /// Create a file client for the given test. + /// + /// The services provider. + /// The name of the test. + /// The test class. + /// A client. + public static PacketFileClient CreateFor(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 + ); + } + + /// + /// Create an instance of PacketFileClient for the given file. + /// + /// The services provider. + /// The file name. + /// A client. + public static PacketFileClient Create(IServiceProvider services, string fileName) + { + return (PacketFileClient)ActivatorUtilities.CreateInstance + (services, typeof(PacketFileClient), new[] { File.OpenRead(fileName) }); + } + + /// + /// Initializes a new instance of the class. + /// + /// The file stream. + /// The packet serializer. + /// The command processor. + /// The packet handler. + /// The logger. + public PacketFileClient + ( + FileStream stream, + IPacketSerializer packetSerializer, + CommandProcessor commandProcessor, + PacketHandler packetHandler, + ILogger logger + ) + : base(commandProcessor, packetSerializer) + { + _stream = stream; + _reader = new StreamReader(_stream); + _packetSerializer = packetSerializer; + _packetHandler = packetHandler; + _logger = logger; + } + + /// + /// Start executing until the given label is hit. + /// + /// The label to hit. + /// An asynchronous operation. + public async Task ExecuteUntilLabelAsync(string label) + { + _readToLabel = false; + _nextLabel = label; + await RunAsync(); + + if (!_readToLabel) + { + throw new Exception($"Label {label} not found."); + } + } + + /// + /// Start executing until the end of the file. + /// + /// An asynchronous operation. + public Task ExecuteToEnd() + { + _nextLabel = null; + return RunAsync(); + } + + /// + /// Skip cursor until the given label is hit. + /// + /// The label to hit. + /// An asynchronous operation. + 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."); + } + } + + /// + public override async Task 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(); + } + + /// + public override Task SendPacketAsync(string packetString, CancellationToken ct = default) + { + return _packetHandler.HandlePacketAsync + ( + this, + PacketSource.Client, + CreatePacket(packetString, PacketSource.Client), + packetString, + ct + ); + } + + /// + public override Task 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; + } + + /// + public void Dispose() + { + _stream.Dispose(); + _reader.Dispose(); + } +} \ No newline at end of file diff --git a/Tests/NosSmooth.Game.Tests/Usings.cs b/Tests/NosSmooth.Game.Tests/Usings.cs new file mode 100644 index 0000000..fd3ae8a --- /dev/null +++ b/Tests/NosSmooth.Game.Tests/Usings.cs @@ -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 -- 2.48.1