diff --git a/tests/MTConnect.NET-Common-Tests/UnixTimeTests.cs b/tests/MTConnect.NET-Common-Tests/UnixTimeTests.cs new file mode 100644 index 000000000..f14499c56 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/UnixTimeTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using NUnit.Framework; + +namespace MTConnect.Tests.Common +{ + /// + /// Pins the contract of , + /// and their round-trip invariants. + /// Every mutation-visible constant, branch, and arithmetic step is + /// covered by at least one assertion so Stryker.NET kills the mutants + /// Stryker previously reported for this module. + /// See TrakHound/MTConnect.NET#242. + /// + [TestFixture] + public class UnixTimeTests + { + // --- Constants --------------------------------------------------------- + + /// Pins the epoch instant to 1970-01-01T00:00:00Z; kills a constant-substitution mutant on the year/month/day/hour/minute/second/Kind arguments. + [Test] + public void EpochTime_IsUnixEpoch_1970_01_01_UtcMidnight() + { + var epoch = UnixTimeExtensions.EpochTime; + Assert.AreEqual(1970, epoch.Year); + Assert.AreEqual(1, epoch.Month); + Assert.AreEqual(1, epoch.Day); + Assert.AreEqual(0, epoch.Hour); + Assert.AreEqual(0, epoch.Minute); + Assert.AreEqual(0, epoch.Second); + Assert.AreEqual(0, epoch.Millisecond); + Assert.AreEqual(DateTimeKind.Utc, epoch.Kind); + } + + /// Pins the constant against a fresh BCL-computed epoch tick count; kills a constant-value mutant on the literal 621355968000000000. + [Test] + public void EpochTicks_Constant_MatchesBclEpochTicks() + { + var bclEpochTicks = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + Assert.AreEqual(bclEpochTicks, UnixTimeExtensions.EpochTicks); + Assert.AreEqual(621355968000000000L, UnixTimeExtensions.EpochTicks); + Assert.AreEqual(UnixTimeExtensions.EpochTime.Ticks, UnixTimeExtensions.EpochTicks); + } + + // --- ToUnixTime -------------------------------------------------------- + + /// Pins the epoch → 0 mapping; kills off-by-one arithmetic mutants on the epoch subtraction. + [Test] + public void ToUnixTime_AtEpoch_ReturnsZero() + { + Assert.AreEqual(0L, UnixTimeExtensions.EpochTime.ToUnixTime()); + } + + /// Pins the one-tick-after-epoch → 1 mapping; kills constant-substitution mutants on the arithmetic. + [Test] + public void ToUnixTime_OneTickAfterEpoch_ReturnsOne() + { + var oneAfter = new DateTime(UnixTimeExtensions.EpochTicks + 1, DateTimeKind.Utc); + Assert.AreEqual(1L, oneAfter.ToUnixTime()); + } + + /// Pins the one-tick-before-epoch → -1 mapping; kills a sign/subtract-swap mutant. + [Test] + public void ToUnixTime_OneTickBeforeEpoch_ReturnsNegativeOne() + { + var oneBefore = new DateTime(UnixTimeExtensions.EpochTicks - 1, DateTimeKind.Utc); + Assert.AreEqual(-1L, oneBefore.ToUnixTime()); + } + + /// Pins the arithmetic on a known-post-epoch Utc value; kills every constant-off arithmetic mutant on the epoch subtraction. + [Test] + public void ToUnixTime_UtcKind_ReturnsUtcTicksMinusEpochTicks() + { + var utc = new DateTime(2026, 1, 1, 12, 30, 45, DateTimeKind.Utc); + var expected = utc.Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, utc.ToUnixTime()); + } + + /// Pins the Local-branch conversion: a Local DateTime is converted to Utc before subtraction; assertion holds in every timezone. + [Test] + public void ToUnixTime_LocalKind_ConvertsToUtcBeforeSubtracting() + { + var local = new DateTime(2026, 1, 1, 12, 30, 45, DateTimeKind.Local); + var expected = TimeZoneInfo.ConvertTimeToUtc(local, TimeZoneInfo.Local).Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, local.ToUnixTime()); + } + + /// Pins the Unspecified-Kind behaviour of : an Unspecified value is treated as UTC (no conversion), so its ticks are subtracted from epoch verbatim. + [Test] + public void ToUnixTime_UnspecifiedKind_TreatedAsUtc() + { + var unspec = new DateTime(2026, 1, 1, 12, 30, 45, DateTimeKind.Unspecified); + var expected = unspec.Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, unspec.ToUnixTime()); + } + + // --- ToUnixUtcTime ----------------------------------------------------- + + /// Pins the Utc no-op path: a Utc DateTime is returned as its tick offset from epoch unchanged. + [Test] + public void ToUnixUtcTime_UtcKind_ReturnsUtcTicksMinusEpochTicks() + { + var utc = new DateTime(2026, 6, 15, 10, 20, 30, DateTimeKind.Utc); + var expected = utc.Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, utc.ToUnixUtcTime()); + } + + /// Pins the Local-branch conversion of ; identical semantics to ToUnixTime for Local inputs. + [Test] + public void ToUnixUtcTime_LocalKind_ConvertsToUtcBeforeSubtracting() + { + var local = new DateTime(2026, 6, 15, 10, 20, 30, DateTimeKind.Local); + var expected = TimeZoneInfo.ConvertTimeToUtc(local, TimeZoneInfo.Local).Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, local.ToUnixUtcTime()); + } + + /// Pins the default Unspecified path: with no unspecifiedAssume argument, the value is treated as Utc and returned unchanged. + [Test] + public void ToUnixUtcTime_UnspecifiedKind_DefaultsToUtcAssumption() + { + var unspec = new DateTime(2026, 6, 15, 10, 20, 30, DateTimeKind.Unspecified); + var expected = unspec.Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, unspec.ToUnixUtcTime()); + } + + /// Pins the explicit-Utc unspecifiedAssume path: identical to the default. + [Test] + public void ToUnixUtcTime_UnspecifiedKind_ExplicitUtcAssumption_ReturnsTicksMinusEpoch() + { + var unspec = new DateTime(2026, 6, 15, 10, 20, 30, DateTimeKind.Unspecified); + var expected = unspec.Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, unspec.ToUnixUtcTime(DateTimeKind.Utc)); + } + + /// Pins the Local unspecifiedAssume path: an Unspecified value is stamped Local, then converted to Utc via the machine timezone. Assertion holds in every timezone. + [Test] + public void ToUnixUtcTime_UnspecifiedKind_LocalAssumption_ConvertsAsLocal() + { + var unspec = new DateTime(2026, 6, 15, 10, 20, 30, DateTimeKind.Unspecified); + var asLocal = DateTime.SpecifyKind(unspec, DateTimeKind.Local); + var expected = TimeZoneInfo.ConvertTimeToUtc(asLocal, TimeZoneInfo.Local).Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, unspec.ToUnixUtcTime(DateTimeKind.Local)); + } + + /// Pins the Unspecified/unspecifiedAssume=Unspecified degenerate path: the SpecifyKind result is Unspecified so the inner Local check is false and the ticks pass through verbatim (identical to the Utc default). + [Test] + public void ToUnixUtcTime_UnspecifiedKind_UnspecifiedAssumption_PassesTicksThrough() + { + var unspec = new DateTime(2026, 6, 15, 10, 20, 30, DateTimeKind.Unspecified); + var expected = unspec.Ticks - UnixTimeExtensions.EpochTicks; + Assert.AreEqual(expected, unspec.ToUnixUtcTime(DateTimeKind.Unspecified)); + } + + /// Pins the ToUnixUTCTime alias: identical result to for every Kind + assumption combination. + [Test] + public void ToUnixUTCTime_Alias_MatchesToUnixUtcTime() + { + var utc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var local = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Local); + var unspec = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + + Assert.AreEqual(utc.ToUnixUtcTime(), utc.ToUnixUTCTime()); + Assert.AreEqual(local.ToUnixUtcTime(), local.ToUnixUTCTime()); + Assert.AreEqual(unspec.ToUnixUtcTime(), unspec.ToUnixUTCTime()); + Assert.AreEqual(unspec.ToUnixUtcTime(DateTimeKind.Local), unspec.ToUnixUTCTime(DateTimeKind.Local)); + Assert.AreEqual(unspec.ToUnixUtcTime(DateTimeKind.Utc), unspec.ToUnixUTCTime(DateTimeKind.Utc)); + } + + // --- FromUnixTime / ToDateTime / ToLocalDateTime ------------------------ + + /// Pins the FromUnixTime(0) → epoch mapping; kills a constant-substitution mutant on the AddTicks argument. + [Test] + public void FromUnixTime_Zero_ReturnsEpochUtc() + { + var d = UnixTimeExtensions.FromUnixTime(0L); + Assert.AreEqual(UnixTimeExtensions.EpochTime, d); + Assert.AreEqual(DateTimeKind.Utc, d.Kind); + } + + /// Pins the FromUnixTime arithmetic on a known-positive tick count. + [Test] + public void FromUnixTime_PositiveTicks_AddsToEpoch() + { + var ticks = 12345678901234L; + var d = UnixTimeExtensions.FromUnixTime(ticks); + Assert.AreEqual(UnixTimeExtensions.EpochTime.AddTicks(ticks), d); + Assert.AreEqual(UnixTimeExtensions.EpochTicks + ticks, d.Ticks); + Assert.AreEqual(DateTimeKind.Utc, d.Kind); + } + + /// Pins the FromUnixTime arithmetic on a negative tick count (pre-epoch instant). + [Test] + public void FromUnixTime_NegativeTicks_SubtractsFromEpoch() + { + var d = UnixTimeExtensions.FromUnixTime(-1L); + Assert.AreEqual(new DateTime(UnixTimeExtensions.EpochTicks - 1, DateTimeKind.Utc), d); + Assert.AreEqual(DateTimeKind.Utc, d.Kind); + } + + /// Pins the ToDateTime alias: identical result to . + [Test] + public void ToDateTime_LongExtension_MatchesFromUnixTime() + { + long[] samples = { 0L, 1L, -1L, 621355968000000000L, -621355968000000000L, 12345678901234L }; + foreach (var ticks in samples) + { + Assert.AreEqual(UnixTimeExtensions.FromUnixTime(ticks), ticks.ToDateTime()); + } + } + + /// Pins the ToLocalDateTime conversion: the returned instant equals FromUnixTime(ticks).ToLocalTime() and has Kind=Local. + [Test] + public void ToLocalDateTime_LongExtension_ReturnsLocalKindAndCorrectInstant() + { + long[] samples = { 0L, 12345678901234L }; + foreach (var ticks in samples) + { + var actual = ticks.ToLocalDateTime(); + var expected = UnixTimeExtensions.FromUnixTime(ticks).ToLocalTime(); + Assert.AreEqual(expected, actual); + Assert.AreEqual(DateTimeKind.Local, actual.Kind); + } + } + + // --- Round-trip invariants -------------------------------------------- + + /// Pins ToUnixTime ∘ FromUnixTime = identity on Utc DateTimes across a spread of instants including epoch, pre-epoch, and MTConnect's contemporary window. + [Test] + public void RoundTrip_UtcDateTime_ToUnixTime_ThenFromUnixTime_IsIdentity() + { + DateTime[] samples = + { + UnixTimeExtensions.EpochTime, + new DateTime(1969, 12, 31, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 6, 15, 12, 30, 45, DateTimeKind.Utc), + new DateTime(2099, 12, 31, 23, 59, 59, DateTimeKind.Utc) + }; + foreach (var d in samples) + { + var round = UnixTimeExtensions.FromUnixTime(d.ToUnixTime()); + Assert.AreEqual(d, round); + Assert.AreEqual(d.Kind, round.Kind); + } + } + + /// Pins ToUnixTime ∘ FromUnixTime = identity on Unspecified DateTimes (treated as Utc): round-trip preserves the tick value, though Kind normalises to Utc on the return. + [Test] + public void RoundTrip_UnspecifiedDateTime_ToUnixTime_ThenFromUnixTime_PreservesTicks() + { + var unspec = new DateTime(2026, 6, 15, 12, 30, 45, DateTimeKind.Unspecified); + var round = UnixTimeExtensions.FromUnixTime(unspec.ToUnixTime()); + Assert.AreEqual(unspec.Ticks, round.Ticks); + Assert.AreEqual(DateTimeKind.Utc, round.Kind); + } + + // --- UnixDateTime.Now -------------------------------------------------- + + /// Pins to a bounded window around the harness-observed BCL UtcNow; kills constant-return mutants (Now → 0, Now → 1, etc.) and off-by-epoch mutants. + [Test] + public void Now_ReturnsCurrentUtcInstantWithinTwoSeconds() + { + var before = DateTime.UtcNow.ToUnixTime(); + var now = UnixDateTime.Now; + var after = DateTime.UtcNow.ToUnixTime(); + + // Two-second window in each direction absorbs GC / scheduler stalls without ever + // spanning the epoch offset a mutation could introduce (epoch offset ~ 6e17 ticks). + var twoSecondsInTicks = TimeSpan.FromSeconds(2).Ticks; + Assert.That(now, Is.GreaterThanOrEqualTo(before - twoSecondsInTicks)); + Assert.That(now, Is.LessThanOrEqualTo(after + twoSecondsInTicks)); + } + + /// Pins that is monotone non-decreasing between two consecutive reads on the same thread. + [Test] + public void Now_TwoConsecutiveReads_AreMonotoneNonDecreasing() + { + var first = UnixDateTime.Now; + var second = UnixDateTime.Now; + Assert.That(second, Is.GreaterThanOrEqualTo(first)); + } + } +}