diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ccdff..aed5b62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,6 +131,10 @@ jobs: dotnet restore F1Server/F1Server.csproj dotnet restore F1Server.Tests/F1Server.Tests.csproj + # sonar.coverage.exclusions only removes the startup/composition-root + # files (host bootstrap, web host wiring, OpenTelemetry setup) from the + # coverage metric; they are still analyzed for issues. They only wire + # up the real runtime environment and are not meaningfully unit-testable. - name: Begin SonarQube analysis if: ${{ env.SONAR_TOKEN != '' }} run: | @@ -141,6 +145,7 @@ jobs: /d:sonar.exclusions="**/Migrations/**,F1ServerApp/**,**/*.packet,docs/**" \ /d:sonar.test.exclusions="**/*.packet" \ /d:sonar.global.exclusions="**/*.packet" \ + /d:sonar.coverage.exclusions="F1Server/Program.cs,F1Server.WebApi/WebHosting.cs,F1Server.Observability/ObservabilityConfiguration.cs" \ /d:sonar.cs.opencover.reportsPaths="${{ runner.temp }}/TestResults/**/coverage.opencover.xml" - name: Build diff --git a/F1Server.Tests/Processors/CarStatusProcessorTests.cs b/F1Server.Tests/Processors/CarStatusProcessorTests.cs new file mode 100644 index 0000000..bca6f35 --- /dev/null +++ b/F1Server.Tests/Processors/CarStatusProcessorTests.cs @@ -0,0 +1,262 @@ +using F1Server.Core.Enumerations; +using F1Server.Core.PacketData; +using F1Server.Core.Packets.Data; +using F1Server.Data; +using F1Server.Db.Entity.Tables; +using F1Server.Service.Processors; +using F1Server.Service.Runtime; +using F1Server.Tests.Data; + +using Microsoft.Extensions.DependencyInjection; + +namespace F1Server.Tests.Processors; + +/// +/// Class to test the car status processor class +/// +[TestClass] +public class CarStatusProcessorTests +{ + #region Constants + + /// + /// Database id of the test session + /// + private const long SessionDbId = 450300; + + #endregion // Constants + + #region Static methods + + /// + /// Creates a recordable session runtime data object + /// + /// Session runtime data + private static SessionRuntimeData CreateSessionRuntimeData() + { + return new SessionRuntimeData(2025, (ulong)SessionDbId, SessionType.Race) + { + HasParticipants = true, + IsRecordable = true, + SessionDbId = SessionDbId, + CurrentSession = new LiveSessionData + { + DbId = SessionDbId, + SessionGameId = (ulong)SessionDbId, + SessionType = SessionType.Race + } + }; + } + + /// + /// Creates a participant on lap 2 with live data and registers it at car index 0 + /// + /// Session runtime data + /// Started current lap of the participant + /// Participant runtime data + private static ParticipantRuntimeData AddParticipant(SessionRuntimeData sessionRuntimeData, out LapEntity currentLap) + { + var participantRuntimeData = new ParticipantRuntimeData(sessionRuntimeData) + { + CurrentLapNumber = 2, + LiveData = new LiveDriverData + { + ArrayIndex = 0 + } + }; + + currentLap = new LapEntity + { + LapNumber = 2 + }; + + Assert.IsTrue(participantRuntimeData.AddLap(currentLap), "The current lap could not be added!"); + Assert.IsTrue(sessionRuntimeData.Participants.TryAdd(0, participantRuntimeData), "Participant runtime data could not be registered!"); + + return participantRuntimeData; + } + + /// + /// Creates a F1 2025 car status packet with the given visual tyre compound on car index 0 + /// + /// Visual tyre compound + /// Car status packet + private static CarStatus CreateCarStatus(VisualTyreCompound visualTyreCompound) + { + var carStatus = new CarStatus2025(); + + carStatus.CarStatusData[0] = new CarStatusData2025 + { + VisualTyreCompound = visualTyreCompound + }; + + var packetHeader = new PacketHeader + { + GameVersion = 2025, + PacketType = PacketTypes.CarStatus, + UniqueSessionId = (ulong)SessionDbId + }; + + return new CarStatus(packetHeader, carStatus); + } + + /// + /// Creates a car status processor + /// + /// Car status packet + /// Service provider + /// Car status processor + private static CarStatusProcessor CreateProcessor(CarStatus carStatus, IServiceProvider serviceProvider) + { + return new CarStatusProcessor(serviceProvider, + carStatus.PacketHeader, + new LiveGameData + { + GameVersion = 2025 + }); + } + + /// + /// Creates a service provider using the given telemetry writer + /// + /// Telemetry writer + /// Service provider + private static ServiceProvider CreateServiceProvider(RecordingTelemetryWriter telemetryWriter) + { + var services = new ServiceCollection(); + + services.AddSingleton(new F1ServerApplicationData + { + TelemetryWriter = telemetryWriter + }); + + return services.BuildServiceProvider(); + } + + #endregion // Static methods + + #region Test methods + + /// + /// The visual tyre compound of the packet is stored at the current lap and the live data of the participant + /// + [TestMethod] + public void CarStatusProcessorVisualTyreCompoundUpdatesCurrentLapAndLiveData() + { + var sessionRuntimeData = CreateSessionRuntimeData(); + var participantRuntimeData = AddParticipant(sessionRuntimeData, out var currentLap); + var carStatus = CreateCarStatus(VisualTyreCompound.Soft); + + var isProcessed = CreateProcessor(carStatus, TestData.ServiceProvider).Process(carStatus, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Car status packet not correctly processed!"); + Assert.AreEqual(VisualTyreCompound.Soft, currentLap.TyreCompound, "The tyre compound should be stored at the current lap!"); + Assert.IsNotNull(participantRuntimeData.LiveData, "The live data of the participant should still be available!"); + Assert.AreEqual(VisualTyreCompound.Soft, participantRuntimeData.LiveData.CurrentUsedTyre, "The tyre compound should be stored at the live data!"); + } + + /// + /// The car status of a human driver is published to the telemetry writer + /// + [TestMethod] + public void CarStatusProcessorHumanDriverPublishesCarStatus() + { + var sessionRuntimeData = CreateSessionRuntimeData(); + var participantRuntimeData = AddParticipant(sessionRuntimeData, out _); + + participantRuntimeData.IsHumanDriver = true; + + var telemetryWriter = new RecordingTelemetryWriter(); + var carStatus = CreateCarStatus(VisualTyreCompound.Medium); + + using (var serviceProvider = CreateServiceProvider(telemetryWriter)) + { + var isProcessed = CreateProcessor(carStatus, serviceProvider).Process(carStatus, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Car status packet not correctly processed!"); + } + + Assert.AreEqual(1, telemetryWriter.CarStatusCount, "The car status of the human driver should be published once!"); + } + + /// + /// The car status of an AI driver is not published to the telemetry writer + /// + [TestMethod] + public void CarStatusProcessorAiDriverDoesNotPublishCarStatus() + { + var sessionRuntimeData = CreateSessionRuntimeData(); + + AddParticipant(sessionRuntimeData, out _); + + var telemetryWriter = new RecordingTelemetryWriter(); + var carStatus = CreateCarStatus(VisualTyreCompound.Medium); + + using (var serviceProvider = CreateServiceProvider(telemetryWriter)) + { + var isProcessed = CreateProcessor(carStatus, serviceProvider).Process(carStatus, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Car status packet not correctly processed!"); + } + + Assert.AreEqual(0, telemetryWriter.CarStatusCount, "The car status of an AI driver must not be published!"); + } + + /// + /// A car status packet without registered participants does not change any participant data + /// + [TestMethod] + public void CarStatusProcessorWithoutParticipantsDoesNotUpdateTyres() + { + var sessionRuntimeData = CreateSessionRuntimeData(); + var participantRuntimeData = AddParticipant(sessionRuntimeData, out var currentLap); + + sessionRuntimeData.HasParticipants = false; + + var carStatus = CreateCarStatus(VisualTyreCompound.Hard); + + var isProcessed = CreateProcessor(carStatus, TestData.ServiceProvider).Process(carStatus, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Car status packet without participants should be processed without an error!"); + Assert.AreEqual(VisualTyreCompound.Unknown, currentLap.TyreCompound, "The tyre compound of the lap must not be changed without participants!"); + Assert.IsNotNull(participantRuntimeData.LiveData, "The live data of the participant should still be available!"); + Assert.AreEqual(VisualTyreCompound.Unknown, participantRuntimeData.LiveData.CurrentUsedTyre, "The tyre compound of the live data must not be changed without participants!"); + } + + /// + /// A car status packet of a not recordable session is ignored + /// + [TestMethod] + public void CarStatusProcessorNotRecordableSessionDoesNotUpdateTyres() + { + var sessionRuntimeData = CreateSessionRuntimeData(); + + AddParticipant(sessionRuntimeData, out var currentLap); + + sessionRuntimeData.IsRecordable = false; + + var carStatus = CreateCarStatus(VisualTyreCompound.Hard); + + var isProcessed = CreateProcessor(carStatus, TestData.ServiceProvider).Process(carStatus, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Car status packet of a not recordable session should be ignored without an error!"); + Assert.AreEqual(VisualTyreCompound.Unknown, currentLap.TyreCompound, "The tyre compound must not be changed for a not recordable session!"); + } + + /// + /// A data object that is no car status packet is ignored without reporting an error + /// + [TestMethod] + public void CarStatusProcessorUnexpectedDataObjectReturnsTrue() + { + var carStatus = CreateCarStatus(VisualTyreCompound.Hard); + var carStatusProcessor = CreateProcessor(carStatus, TestData.ServiceProvider); + + var isProcessed = carStatusProcessor.Process(new object(), CreateSessionRuntimeData()); + + Assert.IsTrue(isProcessed, "An unexpected data object should be ignored without an error!"); + Assert.AreEqual(string.Empty, carStatusProcessor.LastException, "Ignoring an unexpected data object must not record an exception!"); + } + + #endregion // Test methods +} \ No newline at end of file diff --git a/F1Server.Tests/Processors/FinalClassificationProcessorTests.cs b/F1Server.Tests/Processors/FinalClassificationProcessorTests.cs index 5194c42..65a5aad 100644 --- a/F1Server.Tests/Processors/FinalClassificationProcessorTests.cs +++ b/F1Server.Tests/Processors/FinalClassificationProcessorTests.cs @@ -2,6 +2,7 @@ using F1Server.Core.Data; using F1Server.Core.Enumerations; using F1Server.Core.PacketData; +using F1Server.Core.Packets.Data; using F1Server.Data; using F1Server.Db.Entity; using F1Server.Db.Entity.Repositories; @@ -18,6 +19,72 @@ namespace F1Server.Tests.Processors; [TestClass] public class FinalClassificationProcessorTests { + #region Static methods + + /// + /// Reads the F1 2025 final classification sample packet + /// + /// Parsed packet header + /// Final classification packet + private static FinalClassificationData GetFinalClassification(out PacketHeader packetHeader) + { + var packetContent = File.ReadAllBytes(@"SampleData/F1-2025-FinalClassification.packet"); + + var packetData = new ReceivedPacketData(); + + packetData.SetRawData(packetContent); + + Assert.IsNotNull(packetData.PacketHeader, "Analyzing the final classification packet failed!"); + + packetHeader = packetData.PacketHeader; + + var finalClassification = new PacketAnalyzer().GetFinalClassificationData(packetHeader, packetContent) as FinalClassificationData; + + Assert.IsNotNull(finalClassification, "The packet is not a final classification packet!"); + + return finalClassification; + } + + /// + /// Creates a session runtime data object with participants + /// + /// Packet header + /// Database id of the session + /// Is the session finished? + /// Session runtime data + private static SessionRuntimeData CreateSessionRuntimeData(PacketHeader packetHeader, long sessionDbId, bool isFinished) + { + return new SessionRuntimeData(2025, packetHeader.UniqueSessionId, SessionType.Race) + { + HasParticipants = true, + SessionDbId = sessionDbId, + CurrentSession = new LiveSessionData + { + DbId = sessionDbId, + SessionGameId = packetHeader.UniqueSessionId, + SessionType = SessionType.Race, + IsFinished = isFinished + } + }; + } + + /// + /// Creates a final classification processor + /// + /// Packet header + /// Final classification processor + private static FinalClassificationProcessor CreateProcessor(PacketHeader packetHeader) + { + return new FinalClassificationProcessor(TestData.ServiceProvider, + packetHeader, + new LiveGameData + { + GameVersion = 2025 + }); + } + + #endregion // Static methods + #region Test methods /// @@ -178,5 +245,85 @@ public void FinalClassificationProcessorInvalidLapsCleanupRemovesLapsAndTelemetr } } + /// + /// The final classification of a registered participant is stored in the database + /// + [TestMethod] + public void FinalClassificationProcessorRegisteredParticipantStoresFinalClassification() + { + var finalClassification = GetFinalClassification(out var packetHeader); + + Assert.IsNotNull(finalClassification.PacketData, "The final classification packet should contain data!"); + + var sessionRuntimeData = CreateSessionRuntimeData(packetHeader, 430600, true); + + Assert.IsTrue(sessionRuntimeData.Participants.TryAdd(0, + new ParticipantRuntimeData(sessionRuntimeData) + { + ParticipantDbId = 430601 + }), + "Participant runtime data could not be registered!"); + + var isProcessed = CreateProcessor(packetHeader).Process(finalClassification, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Final classification packet not correctly processed!"); + + using (var dbFactory = RepositoryFactory.CreateInstance()) + { + var storedClassification = dbFactory.GetRepository() + ?.GetQuery() + ?.FirstOrDefault(f => f.SessionId == 430600 && f.ParticipantId == 430601); + + Assert.IsNotNull(storedClassification, "The final classification of the registered participant should be stored!"); + Assert.AreEqual(finalClassification.PacketData.FinalClassifications[0].Position, storedClassification.FinishPosition, "The finish position should be taken from the packet!"); + } + } + + /// + /// A final classification packet of a session that is not finished is ignored + /// + [TestMethod] + public void FinalClassificationProcessorUnfinishedSessionIsIgnored() + { + var finalClassification = GetFinalClassification(out var packetHeader); + var sessionRuntimeData = CreateSessionRuntimeData(packetHeader, 430610, false); + + Assert.IsTrue(sessionRuntimeData.Participants.TryAdd(0, + new ParticipantRuntimeData(sessionRuntimeData) + { + ParticipantDbId = 430611 + }), + "Participant runtime data could not be registered!"); + + var isProcessed = CreateProcessor(packetHeader).Process(finalClassification, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "A final classification packet of an unfinished session should be ignored without failing!"); + + using (var dbFactory = RepositoryFactory.CreateInstance()) + { + var hasClassification = dbFactory.GetRepository() + ?.GetQuery() + ?.Any(f => f.SessionId == 430610); + + Assert.IsFalse(hasClassification, "No final classification should be stored for an unfinished session!"); + } + } + + /// + /// A data object that is no final classification packet is ignored without recording an exception + /// + [TestMethod] + public void FinalClassificationProcessorUnexpectedDataObjectReturnsTrue() + { + GetFinalClassification(out var packetHeader); + + var finalClassificationProcessor = CreateProcessor(packetHeader); + + var isProcessed = finalClassificationProcessor.Process(new object(), CreateSessionRuntimeData(packetHeader, 430620, true)); + + Assert.IsTrue(isProcessed, "An unexpected data object should be ignored without an error!"); + Assert.AreEqual(string.Empty, finalClassificationProcessor.LastException, "Ignoring an unexpected data object must not record an exception!"); + } + #endregion // Test methods } \ No newline at end of file diff --git a/F1Server.Tests/Processors/LapDataProcessorTests.cs b/F1Server.Tests/Processors/LapDataProcessorTests.cs new file mode 100644 index 0000000..29e3bb1 --- /dev/null +++ b/F1Server.Tests/Processors/LapDataProcessorTests.cs @@ -0,0 +1,814 @@ +using F1Server.Core.Enumerations; +using F1Server.Core.PacketData; +using F1Server.Core.Packets.Data; +using F1Server.Core.Packets.Interfaces; +using F1Server.Data; +using F1Server.Db.Entity.Tables; +using F1Server.Service.Processors; +using F1Server.Service.Runtime; +using F1Server.Tests.Data; + +using Microsoft.Extensions.DependencyInjection; + +namespace F1Server.Tests.Processors; + +/// +/// Class to test the lap data processor class +/// +[TestClass] +public class LapDataProcessorTests +{ + #region Constants + + /// + /// Database id of the test session + /// + private const long SessionDbId = 450100; + + /// + /// Current lap time of the synthetic lap in milliseconds + /// + private const uint CurrentLapTimeMs = 90500; + + /// + /// Last lap time of the synthetic lap in milliseconds + /// + private const uint LastLapTimeMs = 91000; + + /// + /// Sector 1 time of the synthetic lap in milliseconds + /// + private const uint Sector1TimeMs = 30250; + + /// + /// Sector 2 time of the synthetic lap in milliseconds + /// + private const uint Sector2TimeMs = 30500; + + #endregion // Constants + + #region Static methods + + /// + /// Creates a recordable session runtime data object + /// + /// Game version + /// Session type + /// Session runtime data + private static SessionRuntimeData CreateSessionRuntimeData(int gameVersion, SessionType sessionType) + { + return new SessionRuntimeData(gameVersion, (ulong)SessionDbId, sessionType) + { + HasParticipants = true, + IsRecordable = true, + SessionDbId = SessionDbId, + CurrentSession = new LiveSessionData + { + DbId = SessionDbId, + SessionGameId = (ulong)SessionDbId, + SessionType = sessionType + } + }; + } + + /// + /// Creates a participant with live data and registers it at car index 0 + /// + /// Session runtime data + /// Database id of the participant + /// Participant runtime data + private static ParticipantRuntimeData AddParticipant(SessionRuntimeData sessionRuntimeData, long participantDbId) + { + var participantRuntimeData = new ParticipantRuntimeData(sessionRuntimeData) + { + IsValidObject = true, + ParticipantDbId = participantDbId, + LiveData = new LiveDriverData + { + ArrayIndex = 0 + } + }; + + Assert.IsTrue(sessionRuntimeData.Participants.TryAdd(0, participantRuntimeData), "Participant runtime data could not be registered!"); + + return participantRuntimeData; + } + + /// + /// Creates a F1 2025 lap data packet with the given lap information on car index 0 + /// + /// Current lap number + /// Driver status + /// Lap distance + /// Last lap time in milliseconds + /// Lap data packet + private static LapData CreateLapData2025(ushort lapNumber, DriverStatus driverStatus, float lapDistance, uint lastLapTime) + { + var lapDataComplete = new LapDataComplete2025(); + + lapDataComplete.LapData[0] = new LapData2025 + { + CurrentLapNumber = lapNumber, + CurrentDriverStatus = driverStatus, + CurrentSector = Sector.Sector1, + LapDistance = lapDistance, + CarPosition = 3, + GridPosition = 4, + CurrentLapTime = CurrentLapTimeMs, + LastLapTime = lastLapTime, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + }; + + return CreateLapData(2025, lapDataComplete); + } + + /// + /// Creates a lap data packet with a flying lap on car index 0 for the given game version + /// + /// Game version + /// Lap data packet + private static LapData CreateFlyingLapData(int gameVersion) + { + ILapDataComplete lapDataComplete = gameVersion switch + { + 2019 => new LapDataComplete2019(), + 2020 => new LapDataComplete2020(), + 2021 => new LapDataComplete2021(), + 2022 => new LapDataComplete2022(), + 2023 => new LapDataComplete2023(), + 2024 => new LapDataComplete2024(), + 2026 => new LapDataComplete2026(), + _ => new LapDataComplete2025() + }; + + lapDataComplete.LapData[0] = gameVersion switch + { + 2019 => new LapData2019 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs / 1000f, + Sector1Time = Sector1TimeMs / 1000f, + Sector2Time = Sector2TimeMs / 1000f + }, + 2020 => new LapData2020 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs / 1000f, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + }, + 2021 => new LapData2021 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + }, + 2022 => new LapData2022 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + }, + 2023 => new LapData2023 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + }, + 2024 => new LapData2024 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + }, + 2026 => new LapData2026 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + }, + _ => new LapData2025 + { + CurrentLapNumber = 1, + CurrentDriverStatus = DriverStatus.FlyingLap, + CurrentLapTime = CurrentLapTimeMs, + Sector1Time = (ushort)Sector1TimeMs, + Sector2Time = (ushort)Sector2TimeMs + } + }; + + return CreateLapData(gameVersion, lapDataComplete); + } + + /// + /// Creates a lap data packet object + /// + /// Game version + /// Lap data of all cars + /// Lap data packet + private static LapData CreateLapData(int gameVersion, ILapDataComplete lapDataComplete) + { + var packetHeader = new PacketHeader + { + GameVersion = (ushort)gameVersion, + PacketType = PacketTypes.LapData, + UniqueSessionId = (ulong)SessionDbId, + FrameIdentifier = 100 + }; + + return new LapData(packetHeader, lapDataComplete); + } + + /// + /// Creates a lap data processor + /// + /// Lap data packet + /// Lap data processor + private static LapDataProcessor CreateProcessor(LapData lapData) + { + return new LapDataProcessor(TestData.ServiceProvider, + lapData.PacketHeader, + new LiveGameData + { + GameVersion = lapData.PacketHeader.GameVersion + }); + } + + /// + /// Creates a lap data processor using the given telemetry writer + /// + /// Lap data packet + /// Telemetry writer + /// Lap data processor + private static LapDataProcessor CreateProcessor(LapData lapData, RecordingTelemetryWriter telemetryWriter) + { + var services = new ServiceCollection(); + + services.AddSingleton(new F1ServerApplicationData + { + TelemetryWriter = telemetryWriter + }); + + return new LapDataProcessor(services.BuildServiceProvider(), + lapData.PacketHeader, + new LiveGameData + { + GameVersion = lapData.PacketHeader.GameVersion + }); + } + + #endregion // Static methods + + #region Test methods + + /// + /// A data object that is no lap data packet is ignored without reporting an error + /// + [TestMethod] + public void LapDataProcessorUnexpectedDataObjectReturnsTrue() + { + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + var lapDataProcessor = CreateProcessor(lapData); + + var isProcessed = lapDataProcessor.Process(new object(), CreateSessionRuntimeData(2025, SessionType.Practice1)); + + Assert.IsTrue(isProcessed, "An unexpected data object should be ignored without an error!"); + Assert.AreEqual(string.Empty, lapDataProcessor.LastException, "Ignoring an unexpected data object must not record an exception!"); + } + + /// + /// A lap data packet of a session that is not recordable is ignored + /// + [TestMethod] + public void LapDataProcessorNotRecordableSessionDoesNotUpdateSession() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + + sessionRuntimeData.IsRecordable = false; + sessionRuntimeData.CurrentSession.CurrentCarsOnTrack = 7; + + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "A lap data packet of a not recordable session should be ignored without an error!"); + Assert.AreEqual(7, sessionRuntimeData.CurrentSession.CurrentCarsOnTrack, "The session information must not be updated for a not recordable session!"); + } + + /// + /// A lap data packet of a finished session is ignored + /// + [TestMethod] + public void LapDataProcessorFinishedSessionDoesNotUpdateSession() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Race); + + sessionRuntimeData.CurrentSession.IsFinished = true; + sessionRuntimeData.CurrentSession.CurrentCarsOnTrack = 7; + + var lapData = CreateLapData2025(1, DriverStatus.OnTrack, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "A lap data packet of a finished session should be ignored without an error!"); + Assert.AreEqual(7, sessionRuntimeData.CurrentSession.CurrentCarsOnTrack, "The session information must not be updated for a finished session!"); + } + + /// + /// A lap data packet without registered participants does not update the session information + /// + [TestMethod] + public void LapDataProcessorWithoutParticipantsDoesNotUpdateSession() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + + sessionRuntimeData.HasParticipants = false; + sessionRuntimeData.CurrentSession.CurrentCarsOnTrack = 7; + + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "A lap data packet without participants should be processed without an error!"); + Assert.AreEqual(7, sessionRuntimeData.CurrentSession.CurrentCarsOnTrack, "The session information must not be updated without participants!"); + } + + /// + /// An error while processing a lap is reported as failed processing with the exception + /// + [TestMethod] + public void LapDataProcessorMissingLapEntryReturnsFalse() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + + AddParticipant(sessionRuntimeData, 450101); + + var lapDataComplete = new LapDataComplete2025(); + + lapDataComplete.LapData[0] = null!; + + var lapData = CreateLapData(2025, lapDataComplete); + var lapDataProcessor = CreateProcessor(lapData); + + var isProcessed = lapDataProcessor.Process(lapData, sessionRuntimeData); + + Assert.IsFalse(isProcessed, "A missing lap entry of a registered participant should fail the processing!"); + Assert.IsFalse(string.IsNullOrEmpty(lapDataProcessor.LastException), "The processing error should be recorded as last exception!"); + } + + /// + /// The lap times of every supported game version are converted into the game independent live data + /// + /// Game version + /// Expected number of driven laps after the first lap entity is created + [TestMethod] + [DataRow(2019, 1)] + [DataRow(2020, 1)] + [DataRow(2021, 0)] + [DataRow(2022, 0)] + [DataRow(2023, 0)] + [DataRow(2024, 0)] + [DataRow(2025, 0)] + [DataRow(2026, 0)] + public void LapDataProcessorFlyingLapUpdatesLiveData(int gameVersion, int expectedLapsDriven) + { + var sessionRuntimeData = CreateSessionRuntimeData(gameVersion, SessionType.Practice1); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450110 + gameVersion); + var lapData = CreateFlyingLapData(gameVersion); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, $"F1 {gameVersion} lap data packet not correctly processed!"); + Assert.IsNotNull(participantRuntimeData.LiveData, "The live data of the participant should still be available!"); + Assert.AreEqual(CurrentLapTimeMs, participantRuntimeData.LiveData.CurrentLapTime, $"The current lap time of F1 {gameVersion} should be converted to milliseconds!"); + Assert.AreEqual(Sector1TimeMs, participantRuntimeData.LiveData.FastestSector1, $"The sector 1 time of F1 {gameVersion} should be the fastest sector 1 of the driver!"); + Assert.AreEqual(Sector2TimeMs, participantRuntimeData.LiveData.FastestSector2, $"The sector 2 time of F1 {gameVersion} should be the fastest sector 2 of the driver!"); + Assert.AreEqual(Sector1TimeMs, sessionRuntimeData.CurrentSession.FastestSector1, $"The sector 1 time of F1 {gameVersion} should be the fastest sector 1 of the session!"); + Assert.AreEqual(Sector2TimeMs, sessionRuntimeData.CurrentSession.FastestSector2, $"The sector 2 time of F1 {gameVersion} should be the fastest sector 2 of the session!"); + Assert.AreEqual(expectedLapsDriven, participantRuntimeData.LiveData.LapsDriven, $"The driven laps of F1 {gameVersion} are not counted as expected!"); + Assert.AreEqual(1, sessionRuntimeData.CurrentSession.CurrentCarsOnTrack, "The car on a flying lap should be counted as on track!"); + Assert.IsNotNull(participantRuntimeData.GetLap(1), "A lap entity should be started for the recordable lap!"); + } + + /// + /// An unsupported game version does not provide any lap times + /// + [TestMethod] + public void LapDataProcessorUnknownGameVersionHasNoLapTimes() + { + var sessionRuntimeData = CreateSessionRuntimeData(2018, SessionType.Practice1); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450130); + var lapData = CreateFlyingLapData(2018); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet of an unknown game version should be processed without an error!"); + Assert.IsNotNull(participantRuntimeData.LiveData, "The live data of the participant should still be available!"); + Assert.AreEqual(0u, participantRuntimeData.LiveData.CurrentLapTime, "An unknown game version must not provide a current lap time!"); + Assert.AreEqual(0u, sessionRuntimeData.CurrentSession.FastestSector1, "An unknown game version must not provide sector times!"); + } + + /// + /// The driver status decides depending on the session type whether a lap is recordable + /// + /// Session type + /// Driver status + /// Current lap number + /// Expected recordable state + /// Expected number of cars on track + [TestMethod] + [DataRow(SessionType.Race, DriverStatus.InLap, (ushort)1, true, 1)] + [DataRow(SessionType.Race2, DriverStatus.OutLap, (ushort)1, true, 1)] + [DataRow(SessionType.Race3, DriverStatus.OnTrack, (ushort)1, true, 1)] + [DataRow(SessionType.Race, DriverStatus.InGarage, (ushort)1, false, 0)] + [DataRow(SessionType.Race, DriverStatus.FlyingLap, (ushort)1, false, 1)] + [DataRow(SessionType.Race, DriverStatus.FlyingLap, (ushort)60, false, 1)] + [DataRow(SessionType.Practice1, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.Practice2, DriverStatus.OnTrack, (ushort)1, false, 1)] + [DataRow(SessionType.Practice3, DriverStatus.InLap, (ushort)1, false, 1)] + [DataRow(SessionType.ShortPractice, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.Qualifying1, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.Qualifying2, DriverStatus.OutLap, (ushort)1, false, 1)] + [DataRow(SessionType.Qualifying3, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.ShortQualifying, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.OneShotQualifying, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.SprintShootout1, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.SprintShootout2, DriverStatus.InLap, (ushort)1, false, 1)] + [DataRow(SessionType.SprintShootout3, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.ShortSprintShootout, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.OneShotSprintShootout, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.TimeTrial, DriverStatus.FlyingLap, (ushort)1, true, 1)] + [DataRow(SessionType.TimeTrial, DriverStatus.OnTrack, (ushort)1, true, 1)] + [DataRow(SessionType.TimeTrial, DriverStatus.InLap, (ushort)1, false, 1)] + [DataRow(SessionType.Unknown, DriverStatus.FlyingLap, (ushort)1, false, 1)] + public void LapDataProcessorDriverStatusDecidesRecordableLap(SessionType sessionType, DriverStatus driverStatus, ushort lapNumber, bool expectedIsRecordable, int expectedCarsOnTrack) + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, sessionType); + + sessionRuntimeData.TotalLaps = 50; + + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450140); + var lapData = CreateLapData2025(lapNumber, driverStatus, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreEqual(expectedIsRecordable, participantRuntimeData.IsOnRecordableLap, $"The recordable state for {driverStatus} in {sessionType} is not as expected!"); + Assert.AreEqual(expectedCarsOnTrack, sessionRuntimeData.CurrentSession.CurrentCarsOnTrack, $"The cars on track for {driverStatus} in {sessionType} are not as expected!"); + Assert.AreEqual(driverStatus, participantRuntimeData.CurrentStatus, "The driver status of the packet should be stored at the participant!"); + } + + /// + /// A new lap number completes the previous lap with its lap time and starts the next lap + /// + [TestMethod] + public void LapDataProcessorNewLapNumberCompletesPreviousLap() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Race); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450150); + + participantRuntimeData.IsHumanDriver = true; + participantRuntimeData.IsOnRecordableLap = true; + participantRuntimeData.CurrentLapNumber = 1; + participantRuntimeData.CurrentPacketLapNumber = 1; + participantRuntimeData.LastLapDistance = 5000; + + Assert.IsTrue(participantRuntimeData.AddLap(new LapEntity + { + LapNumber = 1, + SessionId = SessionDbId, + ParticipantId = 450150, + Sector1Time = Sector1TimeMs, + Sector2Time = Sector2TimeMs + }), + "The previous lap could not be added!"); + + var lapData = CreateLapData2025(2, DriverStatus.OnTrack, 10, LastLapTimeMs); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreEqual(LastLapTimeMs, participantRuntimeData.LastLapTime, "The last lap time should be taken from the packet!"); + Assert.AreEqual(1, participantRuntimeData.LastLapTimeNumber, "The last lap time should belong to the previous lap!"); + Assert.IsNull(participantRuntimeData.GetLap(1), "The completed previous lap should no longer be an unfinished lap!"); + Assert.IsNotNull(participantRuntimeData.GetLap(2), "The next lap should be started!"); + Assert.AreEqual((ushort)2, participantRuntimeData.CurrentLapNumber, "The current lap number should be taken from the packet!"); + Assert.IsTrue(participantRuntimeData.IsNewTelemetry, "A new lap should request a new telemetry buffer!"); + } + + /// + /// A previous lap without sector times keeps its lap time but is not completed + /// + [TestMethod] + public void LapDataProcessorPreviousLapWithoutSectorsIsNotCompleted() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Race); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450160); + + participantRuntimeData.IsOnRecordableLap = true; + participantRuntimeData.CurrentLapNumber = 1; + participantRuntimeData.CurrentPacketLapNumber = 1; + + var previousLap = new LapEntity + { + LapNumber = 1, + SessionId = SessionDbId, + ParticipantId = 450160 + }; + + Assert.IsTrue(participantRuntimeData.AddLap(previousLap), "The previous lap could not be added!"); + + var lapData = CreateLapData2025(2, DriverStatus.OnTrack, 10, LastLapTimeMs); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreSame(previousLap, participantRuntimeData.GetLap(1), "The previous lap without sector times should stay an unfinished lap!"); + Assert.AreEqual(LastLapTimeMs, previousLap.LapTime, "The lap time should be stored at the previous lap!"); + Assert.IsFalse(previousLap.IsCompleted, "A lap without sector times must not be completed!"); + } + + /// + /// A lap distance reset with an unchanged lap number moves the participant to the next lap + /// + [TestMethod] + public void LapDataProcessorLapDistanceResetMovesToNextLap() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Race); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450170); + + participantRuntimeData.IsOnRecordableLap = true; + participantRuntimeData.CurrentLapNumber = 1; + participantRuntimeData.CurrentPacketLapNumber = 1; + participantRuntimeData.LastLapDistance = 5000; + + Assert.IsTrue(participantRuntimeData.AddLap(new LapEntity + { + LapNumber = 1, + SessionId = SessionDbId, + ParticipantId = 450170, + Sector1Time = Sector1TimeMs, + Sector2Time = Sector2TimeMs + }), + "The previous lap could not be added!"); + + var lapData = CreateLapData2025(1, DriverStatus.OnTrack, 10, LastLapTimeMs); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.IsTrue(participantRuntimeData.IsOnNextLap, "The participant should be marked as being on the next lap!"); + Assert.AreEqual((ushort)2, participantRuntimeData.CurrentLapNumber, "The lap number should be increased although the packet still reports the old lap!"); + Assert.IsNotNull(participantRuntimeData.GetLap(2), "The next lap should be started!"); + } + + /// + /// The next lap marker is reset as soon as the packet reports the expected lap number + /// + [TestMethod] + public void LapDataProcessorMatchingLapNumberResetsNextLapMarker() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450180); + + participantRuntimeData.IsOnNextLap = true; + participantRuntimeData.CurrentLapNumber = 2; + participantRuntimeData.CurrentPacketLapNumber = 1; + + var lapData = CreateLapData2025(2, DriverStatus.InLap, 10, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.IsFalse(participantRuntimeData.IsOnNextLap, "The next lap marker should be reset once the packet reports the expected lap number!"); + } + + /// + /// Leaving the track during a recordable lap removes the started lap and its telemetry + /// + [TestMethod] + public void LapDataProcessorLeavingTrackRemovesStartedLap() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450190); + + participantRuntimeData.IsOnRecordableLap = true; + participantRuntimeData.CarIsOnTrack = true; + participantRuntimeData.CurrentLapNumber = 3; + participantRuntimeData.CurrentPacketLapNumber = 3; + participantRuntimeData.LastLapSector = Sector.Sector1; + + Assert.IsTrue(participantRuntimeData.AddLap(new LapEntity + { + LapNumber = 3, + SessionId = SessionDbId, + ParticipantId = 450190 + }), + "The started lap could not be added!"); + + participantRuntimeData.AddTelemetryData(3, new CarTelemetryEntity()); + + var lapData = CreateLapData2025(3, DriverStatus.InGarage, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.IsFalse(participantRuntimeData.IsOnRecordableLap, "A car in the garage must not be on a recordable lap!"); + Assert.IsNull(participantRuntimeData.GetLap(3), "The started lap should be removed after leaving the track!"); + Assert.IsFalse(participantRuntimeData.ClearTelemetryData(3), "The buffered telemetry of the started lap should already be cleared!"); + Assert.AreEqual(Sector.Unknown, participantRuntimeData.LastLapSector, "The sector of a not recordable lap should be unknown!"); + Assert.AreEqual(0, sessionRuntimeData.CurrentSession.CurrentCarsOnTrack, "A car in the garage must not be counted as on track!"); + } + + /// + /// Without any lap times the time table is built from the grid positions + /// + [TestMethod] + public void LapDataProcessorGridPositionsBuildTimeTable() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Race); + + sessionRuntimeData.CurrentSession.Drivers.Add(new LiveDriverData + { + ArrayIndex = 0, + GridPosition = 2, + CarPosition = 1 + }); + sessionRuntimeData.CurrentSession.Drivers.Add(new LiveDriverData + { + ArrayIndex = 1, + GridPosition = 1, + CarPosition = 2 + }); + sessionRuntimeData.CurrentSession.Drivers.Add(new LiveDriverData + { + ArrayIndex = 2 + }); + + var lapData = CreateLapData2025(1, DriverStatus.OnTrack, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreSequenceEqual(new List + { + 1, + 0 + }, + sessionRuntimeData.CurrentSession.TimeTable, + "The time table should be ordered by grid position and skip drivers without one!"); + } + + /// + /// Without lap times and grid positions the time table is built from the car positions + /// + [TestMethod] + public void LapDataProcessorCarPositionsBuildTimeTable() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + + sessionRuntimeData.CurrentSession.Drivers.Add(new LiveDriverData + { + ArrayIndex = 0, + CarPosition = 2 + }); + sessionRuntimeData.CurrentSession.Drivers.Add(new LiveDriverData + { + ArrayIndex = 1, + CarPosition = 1 + }); + sessionRuntimeData.CurrentSession.Drivers.Add(new LiveDriverData + { + ArrayIndex = 2 + }); + + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreSequenceEqual(new List + { + 1, + 0 + }, + sessionRuntimeData.CurrentSession.TimeTable, + "The time table should be ordered by car position and skip drivers without one!"); + } + + /// + /// Without lap times, grid positions and car positions the time table stays empty + /// + [TestMethod] + public void LapDataProcessorNoPositionsKeepTimeTableEmpty() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + + sessionRuntimeData.CurrentSession.Drivers.Add(new LiveDriverData + { + ArrayIndex = 0 + }); + + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.IsEmpty(sessionRuntimeData.CurrentSession.TimeTable, "The time table should stay empty without any positions!"); + } + + /// + /// Only faster sector times replace the stored fastest sector times of the driver and the session + /// + /// Fastest sector time of the driver before processing + /// Fastest sector time of the session before processing + /// Expected fastest sector 1 time of the driver + /// Expected fastest sector 1 time of the session + /// Expected array index of the fastest driver of the session + [TestMethod] + [DataRow(20000u, 20000u, 20000u, 20000u, 5)] + [DataRow(40000u, 20000u, Sector1TimeMs, 20000u, 5)] + [DataRow(40000u, 40000u, Sector1TimeMs, Sector1TimeMs, 0)] + public void LapDataProcessorFasterSectorReplacesFastestSector(uint driverFastestSector, uint sessionFastestSector, uint expectedDriverFastestSector1, uint expectedSessionFastestSector1, int expectedSessionFastestDriver) + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + + sessionRuntimeData.CurrentSession.FastestSector1 = sessionFastestSector; + sessionRuntimeData.CurrentSession.FastestSector1Driver = 5; + sessionRuntimeData.CurrentSession.FastestSector2 = sessionFastestSector; + sessionRuntimeData.CurrentSession.FastestSector2Driver = 5; + + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450200); + + Assert.IsNotNull(participantRuntimeData.LiveData, "The live data of the participant should be available!"); + + participantRuntimeData.LiveData.FastestSector1 = driverFastestSector; + participantRuntimeData.LiveData.FastestSector2 = driverFastestSector; + + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + + var isProcessed = CreateProcessor(lapData).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreEqual(expectedDriverFastestSector1, participantRuntimeData.LiveData.FastestSector1, "The fastest sector 1 of the driver is not as expected!"); + Assert.AreEqual(expectedSessionFastestSector1, sessionRuntimeData.CurrentSession.FastestSector1, "The fastest sector 1 of the session is not as expected!"); + Assert.AreEqual(expectedSessionFastestDriver, sessionRuntimeData.CurrentSession.FastestSector1Driver, "The fastest sector 1 driver of the session is not as expected!"); + Assert.AreEqual(expectedSessionFastestDriver, sessionRuntimeData.CurrentSession.FastestSector2Driver, "The fastest sector 2 driver of the session is not as expected!"); + } + + /// + /// The lap and session data of a human driver are published to the telemetry writer + /// + [TestMethod] + public void LapDataProcessorHumanDriverPublishesTelemetry() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + var participantRuntimeData = AddParticipant(sessionRuntimeData, 450210); + + participantRuntimeData.IsHumanDriver = true; + + var telemetryWriter = new RecordingTelemetryWriter(); + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + + var isProcessed = CreateProcessor(lapData, telemetryWriter).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreEqual(1, telemetryWriter.LapDataCount, "The lap data of the human driver should be published once!"); + Assert.AreEqual(1, telemetryWriter.SessionDataCount, "The session data of the human driver should be published once!"); + } + + /// + /// The lap and session data of an AI driver are not published to the telemetry writer + /// + [TestMethod] + public void LapDataProcessorAiDriverDoesNotPublishTelemetry() + { + var sessionRuntimeData = CreateSessionRuntimeData(2025, SessionType.Practice1); + + AddParticipant(sessionRuntimeData, 450220); + + var telemetryWriter = new RecordingTelemetryWriter(); + var lapData = CreateLapData2025(1, DriverStatus.FlyingLap, 100, 0); + + var isProcessed = CreateProcessor(lapData, telemetryWriter).Process(lapData, sessionRuntimeData); + + Assert.IsTrue(isProcessed, "Lap data packet not correctly processed!"); + Assert.AreEqual(0, telemetryWriter.LapDataCount, "The lap data of an AI driver must not be published!"); + Assert.AreEqual(0, telemetryWriter.SessionDataCount, "The session data of an AI driver must not be published!"); + } + + #endregion // Test methods +} \ No newline at end of file diff --git a/F1Server.Tests/Processors/LapPositionsProcessorTests.cs b/F1Server.Tests/Processors/LapPositionsProcessorTests.cs new file mode 100644 index 0000000..a8062df --- /dev/null +++ b/F1Server.Tests/Processors/LapPositionsProcessorTests.cs @@ -0,0 +1,113 @@ +using F1Server.Core.Enumerations; +using F1Server.Core.PacketData; +using F1Server.Core.Packets.Data; +using F1Server.Data; +using F1Server.Service.Processors; +using F1Server.Service.Runtime; +using F1Server.Tests.Data; + +namespace F1Server.Tests.Processors; + +/// +/// Class to test the lap positions processor class +/// +[TestClass] +public class LapPositionsProcessorTests +{ + #region Static methods + + /// + /// Creates a session runtime data object + /// + /// Has the session participants? + /// Session runtime data + private static SessionRuntimeData CreateSessionRuntimeData(bool hasParticipants) + { + return new SessionRuntimeData(2026, 450500, SessionType.Race) + { + HasParticipants = hasParticipants + }; + } + + /// + /// Creates a F1 2026 lap positions packet + /// + /// Lap positions packet + private static LapPositions CreateLapPositions() + { + var packetHeader = new PacketHeader + { + GameVersion = 2026, + PacketType = PacketTypes.LapPositions, + UniqueSessionId = 450500 + }; + + return new LapPositions(packetHeader, + new LapPositions2026 + { + NumberOfLaps = 1 + }); + } + + /// + /// Creates a lap positions processor + /// + /// Lap positions processor + private static LapPositionsProcessor CreateProcessor() + { + return new LapPositionsProcessor(TestData.ServiceProvider, + new PacketHeader(), + new LiveGameData + { + GameVersion = 2026 + }); + } + + #endregion // Static methods + + #region Test methods + + /// + /// A lap positions packet of a session with participants is processed + /// + [TestMethod] + public void LapPositionsProcessorSessionWithParticipantsReturnsTrue() + { + var lapPositionsProcessor = CreateProcessor(); + + var isProcessed = lapPositionsProcessor.Process(CreateLapPositions(), CreateSessionRuntimeData(true)); + + Assert.IsTrue(isProcessed, "Lap positions packet not correctly processed!"); + Assert.AreEqual(string.Empty, lapPositionsProcessor.LastException, "Processing a lap positions packet must not record an exception!"); + } + + /// + /// A lap positions packet of a session without participants is ignored without failing + /// + [TestMethod] + public void LapPositionsProcessorSessionWithoutParticipantsReturnsTrue() + { + var lapPositionsProcessor = CreateProcessor(); + + var isProcessed = lapPositionsProcessor.Process(CreateLapPositions(), CreateSessionRuntimeData(false)); + + Assert.IsTrue(isProcessed, "A lap positions packet without participants should be ignored without failing!"); + Assert.AreEqual(string.Empty, lapPositionsProcessor.LastException, "Ignoring a lap positions packet must not record an exception!"); + } + + /// + /// A data object that is no lap positions packet is ignored without failing + /// + [TestMethod] + public void LapPositionsProcessorUnexpectedDataObjectReturnsTrue() + { + var lapPositionsProcessor = CreateProcessor(); + + var isProcessed = lapPositionsProcessor.Process(new object(), null); + + Assert.IsTrue(isProcessed, "An unexpected data object should be ignored without an error!"); + Assert.AreEqual(string.Empty, lapPositionsProcessor.LastException, "Ignoring an unexpected data object must not record an exception!"); + } + + #endregion // Test methods +} \ No newline at end of file diff --git a/F1Server.Tests/Processors/RecordingTelemetryWriter.cs b/F1Server.Tests/Processors/RecordingTelemetryWriter.cs new file mode 100644 index 0000000..c2cc71e --- /dev/null +++ b/F1Server.Tests/Processors/RecordingTelemetryWriter.cs @@ -0,0 +1,65 @@ +using F1Server.Core.Interfaces; +using F1Server.Core.Packets.Interfaces; + +namespace F1Server.Tests.Processors; + +/// +/// Telemetry writer counting the written data to test the telemetry publishing of the processors +/// +internal sealed class RecordingTelemetryWriter : ITelemetryWriter +{ + #region Properties + + /// + /// Number of written session data entries + /// + public int SessionDataCount { get; private set; } + + /// + /// Number of written lap data entries + /// + public int LapDataCount { get; private set; } + + /// + /// Number of written car telemetry entries + /// + public int CarTelemetryCount { get; private set; } + + /// + /// Number of written car status entries + /// + public int CarStatusCount { get; private set; } + + #endregion // Properties + + #region ITelemetryWriter + + /// + public bool IsReady => true; + + /// + public void WriteSessionData(ISessionRuntimeData sessionRuntimeData, ILiveDriverData liveDriverData) + { + SessionDataCount++; + } + + /// + public void WriteLapData(IIndependentLapData lapData, ILapDataBase lapInfo, ISessionRuntimeData sessionRuntimeData) + { + LapDataCount++; + } + + /// + public void WriteCarTelemetry(ICarTelemetryDataBase carTelemetryData, ISessionRuntimeData sessionRuntimeData, int currentLapNumber) + { + CarTelemetryCount++; + } + + /// + public void WriteCarStatus(ICarStatusDataBase carStatusData, ISessionRuntimeData sessionRuntimeData, int currentLapNumber) + { + CarStatusCount++; + } + + #endregion // ITelemetryWriter +} \ No newline at end of file diff --git a/F1Server.Tests/Processors/TimeTrialProcessorTests.cs b/F1Server.Tests/Processors/TimeTrialProcessorTests.cs new file mode 100644 index 0000000..a0e8283 --- /dev/null +++ b/F1Server.Tests/Processors/TimeTrialProcessorTests.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; + +using F1Server.Core.Enumerations; +using F1Server.Core.Observability; +using F1Server.Core.PacketData; +using F1Server.Core.Packets.Data; +using F1Server.Data; +using F1Server.Service.Processors; +using F1Server.Service.Runtime; +using F1Server.Tests.Data; + +namespace F1Server.Tests.Processors; + +/// +/// Class to test the time trial processor class +/// +[TestClass] +public class TimeTrialProcessorTests +{ + #region Static methods + + /// + /// Creates a time trial session runtime data object + /// + /// Has the session participants? + /// Session runtime data + private static SessionRuntimeData CreateSessionRuntimeData(bool hasParticipants) + { + return new SessionRuntimeData(2025, 450400, SessionType.TimeTrial) + { + HasParticipants = hasParticipants + }; + } + + /// + /// Creates a F1 2025 time trial packet with the given team ids + /// + /// Team id of the player session best data set + /// Team id of the personal best data set + /// Team id of the rival data set + /// Time trial packet + private static TimeTrialData CreateTimeTrialData(ushort playerSessionTeamId, ushort personalTeamId, ushort rivalTeamId) + { + var timeTrialData = new TimeTrialData2025(); + + timeTrialData.PlayerSessionBestDataSet.TeamId = playerSessionTeamId; + timeTrialData.PersonalBestDataSet.TeamId = personalTeamId; + timeTrialData.RivalDataSet.TeamId = rivalTeamId; + + var packetHeader = new PacketHeader + { + GameVersion = 2025, + PacketType = PacketTypes.TimeTrial, + UniqueSessionId = 450400 + }; + + return new TimeTrialData(packetHeader, timeTrialData); + } + + /// + /// Creates a time trial processor + /// + /// Time trial processor + private static TimeTrialProcessor CreateProcessor() + { + return new TimeTrialProcessor(TestData.ServiceProvider, + new PacketHeader(), + new LiveGameData + { + GameVersion = 2025 + }); + } + + /// + /// Attaches an to , processes the data object and returns the recorded span + /// + /// Data object to process + /// Session runtime data + /// Processing result + /// The recorded span, or null if none was recorded + private static Activity? ProcessWithListener(object dataObject, SessionRuntimeData sessionRuntimeData, out bool isProcessed) + { + Activity? recordedActivity = null; + + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == AppActivity.SrvSource.Name, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + if (activity.OperationName == nameof(TimeTrialProcessor)) + { + recordedActivity = activity; + } + } + }; + + ActivitySource.AddActivityListener(listener); + + isProcessed = CreateProcessor().Process(dataObject, sessionRuntimeData); + + return recordedActivity; + } + + #endregion // Static methods + + #region Test methods + + /// + /// A time trial packet with team ids records the teams of all data sets at the tracing span + /// + [TestMethod] + public void TimeTrialProcessorTeamIdsAddTeamTags() + { + var timeTrialData = CreateTimeTrialData(1, 2, 3); + + var recordedActivity = ProcessWithListener(timeTrialData, CreateSessionRuntimeData(true), out var isProcessed); + + Assert.IsTrue(isProcessed, "Time trial packet not correctly processed!"); + Assert.IsNotNull(recordedActivity, "Processing the time trial packet should record a tracing span!"); + Assert.AreEqual(ActivityStatusCode.Ok, recordedActivity.Status, "The tracing span of a processed time trial packet should be Ok!"); + + var tagKeys = recordedActivity.TagObjects.Select(t => t.Key).ToList(); + + Assert.Contains("f1.player_session_team", tagKeys, "The team of the player session best should be recorded!"); + Assert.Contains("f1.personal_team", tagKeys, "The team of the personal best should be recorded!"); + Assert.Contains("f1.rival_team", tagKeys, "The team of the rival should be recorded!"); + } + + /// + /// A time trial packet of a session without participants is reported as error at the tracing span + /// + [TestMethod] + public void TimeTrialProcessorWithoutParticipantsRecordsErrorStatus() + { + var timeTrialData = CreateTimeTrialData(1, 2, 3); + + var recordedActivity = ProcessWithListener(timeTrialData, CreateSessionRuntimeData(false), out var isProcessed); + + Assert.IsTrue(isProcessed, "A time trial packet without participants should be ignored without failing!"); + Assert.IsNotNull(recordedActivity, "Ignoring the time trial packet should record a tracing span!"); + Assert.AreEqual(ActivityStatusCode.Error, recordedActivity.Status, "The tracing span of an ignored time trial packet should report an error!"); + } + + /// + /// A data object that is no time trial packet is ignored without recording an exception + /// + [TestMethod] + public void TimeTrialProcessorUnexpectedDataObjectReturnsTrue() + { + var timeTrialProcessor = CreateProcessor(); + + var isProcessed = timeTrialProcessor.Process(new object(), CreateSessionRuntimeData(true)); + + Assert.IsTrue(isProcessed, "An unexpected data object should be ignored without an error!"); + Assert.AreEqual(string.Empty, timeTrialProcessor.LastException, "Ignoring an unexpected data object must not record an exception!"); + } + + #endregion // Test methods +} \ No newline at end of file diff --git a/F1Server.Tests/TelemetryConfigurationTests.cs b/F1Server.Tests/TelemetryConfigurationTests.cs new file mode 100644 index 0000000..2b333bb --- /dev/null +++ b/F1Server.Tests/TelemetryConfigurationTests.cs @@ -0,0 +1,54 @@ +using F1Server.Telemetry; + +namespace F1Server.Tests; + +/// +/// Class to test the telemetry configuration class +/// +[TestClass] +public class TelemetryConfigurationTests +{ + #region Test methods + + /// + /// A complete set of connection data marks the telemetry configuration as configured + /// + [TestMethod] + public void TelemetryConfigurationSetConfigurationDataCompleteDataReturnsTrue() + { + var telemetryConfiguration = new TelemetryConfiguration(); + + var isConfigured = telemetryConfiguration.SetConfigurationData("http://localhost:8086", "bucket", "organization", "token"); + + Assert.IsTrue(isConfigured, "Complete connection data should configure the telemetry!"); + Assert.IsTrue(telemetryConfiguration.IsConfigured, "The configured state should be stored!"); + Assert.AreEqual("http://localhost:8086", telemetryConfiguration.InfluxDbHost, "The InfluxDB host should be stored!"); + Assert.AreEqual("bucket", telemetryConfiguration.Bucket, "The bucket should be stored!"); + Assert.AreEqual("organization", telemetryConfiguration.Organization, "The organization should be stored!"); + Assert.AreEqual("token", telemetryConfiguration.Token, "The token should be stored!"); + } + + /// + /// A missing connection value leaves the telemetry configuration unconfigured + /// + /// InfluxDB host + /// Bucket + /// Organization + /// Token + [TestMethod] + [DataRow("", "bucket", "organization", "token")] + [DataRow("http://localhost:8086", "", "organization", "token")] + [DataRow("http://localhost:8086", "bucket", "", "token")] + [DataRow("http://localhost:8086", "bucket", "organization", "")] + public void TelemetryConfigurationSetConfigurationDataMissingValueReturnsFalse(string influxDbHost, string bucket, string organization, string token) + { + var telemetryConfiguration = new TelemetryConfiguration(); + + var isConfigured = telemetryConfiguration.SetConfigurationData(influxDbHost, bucket, organization, token); + + Assert.IsFalse(isConfigured, "Incomplete connection data must not configure the telemetry!"); + Assert.IsFalse(telemetryConfiguration.IsConfigured, "The unconfigured state should be stored!"); + } + + #endregion // Test methods +} \ No newline at end of file