diff --git a/dotnet/SafelyYou.slnx b/dotnet/SafelyYou.slnx
index f93bb03..1cbfb08 100644
--- a/dotnet/SafelyYou.slnx
+++ b/dotnet/SafelyYou.slnx
@@ -1,3 +1,4 @@
+
diff --git a/dotnet/SafelyYouTests/DeviceHeartBeatHistoryTests.cs b/dotnet/SafelyYouTests/DeviceHeartBeatHistoryTests.cs
new file mode 100644
index 0000000..d989f02
--- /dev/null
+++ b/dotnet/SafelyYouTests/DeviceHeartBeatHistoryTests.cs
@@ -0,0 +1,111 @@
+using SafelyYou;
+
+namespace SafelyYouTests;
+
+public class DeviceHeartBeatHistoryTests
+{
+ private static readonly DateTimeOffset T0 = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
+ private static readonly DateTimeOffset T1 = T0.AddMinutes(1);
+ private static readonly DateTimeOffset T2 = T0.AddMinutes(2);
+
+ [Test]
+ public void Add_FirstHeartbeat_SetsFirstLastAndCount()
+ {
+ var history = new DeviceHeartBeatHistory();
+
+ var result = history.Add(T0);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
+ Assert.That(history.NumberOfHeartBeats, Is.EqualTo(1));
+ Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
+ Assert.That(history.LastHeartBeat, Is.EqualTo(T0));
+ });
+ }
+
+ [Test]
+ public void Add_LaterHeartbeat_UpdatesLastAndIncrementsCount()
+ {
+ var history = new DeviceHeartBeatHistory();
+ history.Add(T0);
+
+ var result = history.Add(T1);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
+ Assert.That(history.NumberOfHeartBeats, Is.EqualTo(2));
+ Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
+ Assert.That(history.LastHeartBeat, Is.EqualTo(T1));
+ });
+ }
+
+ [Test]
+ public void Add_SameTimestampAsLast_IsAccepted()
+ {
+ var history = new DeviceHeartBeatHistory();
+ history.Add(T0);
+
+ var result = history.Add(T0);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
+ Assert.That(history.NumberOfHeartBeats, Is.EqualTo(2));
+ Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
+ Assert.That(history.LastHeartBeat, Is.EqualTo(T0));
+ });
+ }
+
+ [Test]
+ public void Add_EarlierThanLast_ReturnsOutOfSequenceAndDoesNotMutate()
+ {
+ var history = new DeviceHeartBeatHistory();
+ history.Add(T1);
+
+ var result = history.Add(T0);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.OutOfSequence));
+ Assert.That(history.NumberOfHeartBeats, Is.EqualTo(1));
+ Assert.That(history.FirstHeartBeat, Is.EqualTo(T1));
+ Assert.That(history.LastHeartBeat, Is.EqualTo(T1));
+ });
+ }
+
+ [Test]
+ public void Add_MultipleInOrder_KeepsFirstAndAdvancesLast()
+ {
+ var history = new DeviceHeartBeatHistory();
+
+ Assert.That(history.Add(T0), Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
+ Assert.That(history.Add(T1), Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
+ Assert.That(history.Add(T2), Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(history.NumberOfHeartBeats, Is.EqualTo(3));
+ Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
+ Assert.That(history.LastHeartBeat, Is.EqualTo(T2));
+ });
+ }
+
+ [Test]
+ public void Add_WithGap_StillCountsHeartbeat()
+ {
+ var history = new DeviceHeartBeatHistory();
+ history.Add(T0);
+
+ var muchLater = T0.AddHours(1);
+ var result = history.Add(muchLater);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
+ Assert.That(history.NumberOfHeartBeats, Is.EqualTo(2));
+ Assert.That(history.LastHeartBeat, Is.EqualTo(muchLater));
+ });
+ }
+}
diff --git a/dotnet/SafelyYouTests/HeartBeatHistoriesUptimeTests.cs b/dotnet/SafelyYouTests/HeartBeatHistoriesUptimeTests.cs
new file mode 100644
index 0000000..f97f1a8
--- /dev/null
+++ b/dotnet/SafelyYouTests/HeartBeatHistoriesUptimeTests.cs
@@ -0,0 +1,89 @@
+using SafelyYou;
+
+namespace SafelyYouTests;
+
+public class HeartBeatHistoriesUptimeTests
+{
+ private static readonly DeviceId Device = new("device-1");
+ private static readonly DateTimeOffset T0 = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ [Test]
+ public void Uptime_UnknownDevice_ReturnsZero()
+ {
+ var histories = new HeartBeatHistories();
+
+ Assert.That(histories.Uptime(Device), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Uptime_SingleHeartbeat_ReturnsZero()
+ {
+ var histories = new HeartBeatHistories();
+ histories.Add(Device, T0);
+
+ // first == last => TotalMinutes is 0, guarded to return 0
+ Assert.That(histories.Uptime(Device), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Uptime_TwoHeartbeatsOneMinuteApart_ReturnsTwoHundred()
+ {
+ var histories = new HeartBeatHistories();
+ histories.Add(Device, T0);
+ histories.Add(Device, T0.AddMinutes(1));
+
+ // (2 beats / 1 minute) * 100
+ Assert.That(histories.Uptime(Device), Is.EqualTo(200).Within(0.001));
+ }
+
+ [Test]
+ public void Uptime_TwoHeartbeatsOverTwoMinutes_ReturnsOneHundred()
+ {
+ var histories = new HeartBeatHistories();
+ histories.Add(Device, T0);
+ histories.Add(Device, T0.AddMinutes(2));
+
+ // (2 beats / 2 minutes) * 100
+ Assert.That(histories.Uptime(Device), Is.EqualTo(100).Within(0.001));
+ }
+
+ [Test]
+ public void Uptime_ThreeHeartbeatsOverTwoMinutes_ReturnsOneHundredFifty()
+ {
+ var histories = new HeartBeatHistories();
+ histories.Add(Device, T0);
+ histories.Add(Device, T0.AddMinutes(1));
+ histories.Add(Device, T0.AddMinutes(2));
+
+ // (3 beats / 2 minutes) * 100
+ Assert.That(histories.Uptime(Device), Is.EqualTo(150).Within(0.001));
+ }
+
+ [Test]
+ public void Uptime_IsPerDevice()
+ {
+ var other = new DeviceId("device-2");
+ var histories = new HeartBeatHistories();
+ histories.Add(Device, T0);
+ histories.Add(Device, T0.AddMinutes(1));
+ histories.Add(other, T0);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(histories.Uptime(Device), Is.EqualTo(200).Within(0.001));
+ Assert.That(histories.Uptime(other), Is.EqualTo(0));
+ });
+ }
+
+ [Test]
+ public void Uptime_OutOfSequenceHeartbeat_DoesNotAffectCalculation()
+ {
+ var histories = new HeartBeatHistories();
+ histories.Add(Device, T0.AddMinutes(1));
+ histories.Add(Device, T0); // rejected
+ histories.Add(Device, T0.AddMinutes(3));
+
+ // still 2 beats over 2 minutes
+ Assert.That(histories.Uptime(Device), Is.EqualTo(100).Within(0.001));
+ }
+}
diff --git a/dotnet/SafelyYouTests/SafelyYouTests.csproj b/dotnet/SafelyYouTests/SafelyYouTests.csproj
new file mode 100644
index 0000000..7009be5
--- /dev/null
+++ b/dotnet/SafelyYouTests/SafelyYouTests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+