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