using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Golden-byte unit tests for : DTL / DATE_AND_TIME / /// S5TIME / TIME / TOD / DATE encode + decode round-trips, plus the /// uninitialized-PLC-buffer rejection paths. These tests don't touch S7.Net — the /// codec operates on raw byte spans, same testing pattern as /// . /// [Trait("Category", "Unit")] public sealed class S7DateTimeCodecTests { // -------- DTL (12 bytes) -------- [Fact] public void EncodeDtl_emits_be_year_then_components_then_be_nanoseconds() { // 2024-01-15 12:34:56.000 → year=0x07E8, mon=01, day=0F, dow=Mon=2, // hour=0x0C, min=0x22, sec=0x38, nanos=0x00000000 var dt = new DateTime(2024, 1, 15, 12, 34, 56, DateTimeKind.Unspecified); var bytes = S7DateTimeCodec.EncodeDtl(dt); bytes.Length.ShouldBe(12); bytes[0].ShouldBe(0x07); bytes[1].ShouldBe(0xE8); bytes[2].ShouldBe(0x01); bytes[3].ShouldBe(0x0F); // 2024-01-15 was a Monday (.NET DayOfWeek=Monday=1); S7 dow = 1+1 = 2. bytes[4].ShouldBe(0x02); bytes[5].ShouldBe(0x0C); bytes[6].ShouldBe(0x22); bytes[7].ShouldBe(0x38); bytes[8].ShouldBe(0x00); bytes[9].ShouldBe(0x00); bytes[10].ShouldBe(0x00); bytes[11].ShouldBe(0x00); } [Fact] public void DecodeDtl_round_trips_encode_with_nanosecond_precision() { // 250 ms = 250_000_000 ns = 0x0EE6B280 var dt = new DateTime(2024, 1, 15, 12, 34, 56, 250, DateTimeKind.Unspecified); var bytes = S7DateTimeCodec.EncodeDtl(dt); var decoded = S7DateTimeCodec.DecodeDtl(bytes); decoded.ShouldBe(dt); } [Fact] public void DecodeDtl_rejects_all_zero_uninitialized_buffer() { // Brand-new DB — PLC hasn't written a real value yet. Must surface as a hard // error, not as year-0001 garbage. var buf = new byte[12]; Should.Throw(() => S7DateTimeCodec.DecodeDtl(buf)); } [Fact] public void DecodeDtl_rejects_out_of_range_components() { // Year 1969 → out of S7 DTL spec range (1970..2554). var buf = new byte[] { 0x07, 0xB1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0 }; Should.Throw(() => S7DateTimeCodec.DecodeDtl(buf)); // Month 13 — invalid. var buf2 = new byte[] { 0x07, 0xE8, 13, 1, 1, 0, 0, 0, 0, 0, 0, 0 }; Should.Throw(() => S7DateTimeCodec.DecodeDtl(buf2)); } [Fact] public void DecodeDtl_rejects_wrong_buffer_length() { Should.Throw(() => S7DateTimeCodec.DecodeDtl(new byte[11])); } [Fact] public void EncodeDtl_rejects_year_outside_spec() { Should.Throw(() => S7DateTimeCodec.EncodeDtl(new DateTime(1969, 1, 1))); } // -------- DATE_AND_TIME (DT, 8 bytes BCD) -------- [Fact] public void EncodeDt_emits_bcd_components() { // 2024-01-15 12:34:56.789, dow Monday → S7 dow = 2. // BCD: yy=24→0x24, mon=01→0x01, day=15→0x15, hh=12→0x12, mm=34→0x34, ss=56→0x56 // ms=789 → bytes[6]=0x78, bytes[7] high nibble=0x9, low nibble=dow=2 → 0x92. var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified); var bytes = S7DateTimeCodec.EncodeDt(dt); bytes.Length.ShouldBe(8); bytes[0].ShouldBe(0x24); bytes[1].ShouldBe(0x01); bytes[2].ShouldBe(0x15); bytes[3].ShouldBe(0x12); bytes[4].ShouldBe(0x34); bytes[5].ShouldBe(0x56); bytes[6].ShouldBe(0x78); bytes[7].ShouldBe(0x92); } [Fact] public void DecodeDt_round_trips_post_2000() { var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified); var bytes = S7DateTimeCodec.EncodeDt(dt); S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt); } [Fact] public void DecodeDt_round_trips_pre_2000_using_90_to_99_year_window() { // 1995-06-30 — yy=95 → year window says 1995. var dt = new DateTime(1995, 6, 30, 0, 0, 0, DateTimeKind.Unspecified); var bytes = S7DateTimeCodec.EncodeDt(dt); bytes[0].ShouldBe(0x95); S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt); } [Fact] public void DecodeDt_rejects_all_zero_uninitialized_buffer() { Should.Throw(() => S7DateTimeCodec.DecodeDt(new byte[8])); } [Fact] public void DecodeDt_rejects_invalid_bcd_nibble() { // Month 0x1A — high nibble OK but low nibble 0xA is not a decimal digit. var buf = new byte[] { 0x24, 0x1A, 0x15, 0x12, 0x34, 0x56, 0x00, 0x02 }; Should.Throw(() => S7DateTimeCodec.DecodeDt(buf)); } [Fact] public void EncodeDt_rejects_year_outside_1990_to_2089_window() { Should.Throw(() => S7DateTimeCodec.EncodeDt(new DateTime(1989, 12, 31))); Should.Throw(() => S7DateTimeCodec.EncodeDt(new DateTime(2090, 1, 1))); } // -------- S5TIME (16 bits BCD) -------- [Fact] public void EncodeS5Time_one_second_uses_10ms_timebase_with_count_100() { // T#1S = 1000 ms = 100 × 10 ms (timebase 0). Layout: 0000 00BB BBBB BBBB // tb=00, count=100=BCD 0x100 → byte0=0x01, byte1=0x00. var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(1)); bytes.Length.ShouldBe(2); bytes[0].ShouldBe(0x01); bytes[1].ShouldBe(0x00); } [Fact] public void EncodeS5Time_picks_10s_timebase_for_long_durations() { // T#9990S = 9990 s = 999 × 10 s (timebase 11). count=999 BCD 0x999. // byte0 = (3 << 4) | 9 = 0x39, byte1 = 0x99. var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(9990)); bytes[0].ShouldBe(0x39); bytes[1].ShouldBe(0x99); } [Fact] public void DecodeS5Time_round_trips_each_timebase() { foreach (var ts in new[] { TimeSpan.FromMilliseconds(10), // tb=00 (10 ms) TimeSpan.FromMilliseconds(500), // tb=00 (10 ms × 50) TimeSpan.FromSeconds(1), // tb=00 (10 ms × 100) TimeSpan.FromSeconds(60), // tb=01 (100 ms × 600)? No — 60s/100ms=600 > 999, picks 1s. TimeSpan.FromMinutes(10), // tb=10 (1 s × 600) TimeSpan.FromMinutes(30), // tb=11 (10 s × 180) }) { var bytes = S7DateTimeCodec.EncodeS5Time(ts); S7DateTimeCodec.DecodeS5Time(bytes).ShouldBe(ts); } } [Fact] public void EncodeS5Time_rejects_negative_or_too_large() { Should.Throw(() => S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(-1))); Should.Throw(() => S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(10_000))); } [Fact] public void EncodeS5Time_rejects_durations_not_representable_in_any_timebase() { // 7 ms — no timebase divides cleanly, would lose precision. Should.Throw(() => S7DateTimeCodec.EncodeS5Time(TimeSpan.FromMilliseconds(7))); } [Fact] public void DecodeS5Time_rejects_invalid_bcd() { // Hi nibble of byte0 has timebase=00 but digit nibble 0xA — illegal. var buf = new byte[] { 0x0A, 0x00 }; Should.Throw(() => S7DateTimeCodec.DecodeS5Time(buf)); } // -------- TIME (Int32 ms BE, signed) -------- [Fact] public void EncodeTime_emits_signed_int32_be_milliseconds() { // T#1S = 1000 ms = 0x000003E8. var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(1)); bytes.ShouldBe(new byte[] { 0x00, 0x00, 0x03, 0xE8 }); } [Fact] public void EncodeTime_supports_negative_durations() { // -1000 ms = 0xFFFFFC18 (two's complement Int32). var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(-1)); bytes.ShouldBe(new byte[] { 0xFF, 0xFF, 0xFC, 0x18 }); } [Fact] public void DecodeTime_round_trips_positive_and_negative() { foreach (var ts in new[] { TimeSpan.Zero, TimeSpan.FromMilliseconds(1), TimeSpan.FromHours(24), TimeSpan.FromMilliseconds(-12345), }) { S7DateTimeCodec.DecodeTime(S7DateTimeCodec.EncodeTime(ts)).ShouldBe(ts); } } [Fact] public void EncodeTime_rejects_overflow() { Should.Throw(() => S7DateTimeCodec.EncodeTime(TimeSpan.FromMilliseconds((long)int.MaxValue + 1))); } // -------- TOD (UInt32 ms BE, 0..86399999) -------- [Fact] public void EncodeTod_emits_unsigned_int32_be_milliseconds() { // 12:00:00.000 = 12 × 3_600_000 = 43_200_000 ms = 0x0293_2E00. var bytes = S7DateTimeCodec.EncodeTod(new TimeSpan(12, 0, 0)); bytes.ShouldBe(new byte[] { 0x02, 0x93, 0x2E, 0x00 }); } [Fact] public void DecodeTod_round_trips_midnight_and_max() { S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(TimeSpan.Zero)).ShouldBe(TimeSpan.Zero); // 23:59:59.999 — last valid TOD. var max = new TimeSpan(0, 23, 59, 59, 999); S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(max)).ShouldBe(max); } [Fact] public void DecodeTod_rejects_value_at_or_above_one_day() { // 86_400_000 ms = 0x0526_5C00 — exactly 24 h, must reject. var buf = new byte[] { 0x05, 0x26, 0x5C, 0x00 }; Should.Throw(() => S7DateTimeCodec.DecodeTod(buf)); } [Fact] public void EncodeTod_rejects_negative_and_overflow() { Should.Throw(() => S7DateTimeCodec.EncodeTod(TimeSpan.FromMilliseconds(-1))); Should.Throw(() => S7DateTimeCodec.EncodeTod(TimeSpan.FromHours(24))); } // -------- DATE (UInt16 BE, days since 1990-01-01) -------- [Fact] public void EncodeDate_emits_be_uint16_days_since_epoch() { // 1990-01-01 → 0 days → 0x0000. S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 1)).ShouldBe(new byte[] { 0x00, 0x00 }); // 1990-01-02 → 1 day → 0x0001. S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 2)).ShouldBe(new byte[] { 0x00, 0x01 }); // 2024-01-15 → 12_432 days = 0x3090. var bytes = S7DateTimeCodec.EncodeDate(new DateTime(2024, 1, 15)); bytes.ShouldBe(new byte[] { 0x30, 0x90 }); } [Fact] public void DecodeDate_round_trips_encode() { foreach (var d in new[] { new DateTime(1990, 1, 1), new DateTime(2000, 2, 29), new DateTime(2024, 1, 15), new DateTime(2099, 12, 31), }) { S7DateTimeCodec.DecodeDate(S7DateTimeCodec.EncodeDate(d)).ShouldBe(d); } } [Fact] public void EncodeDate_rejects_pre_epoch() { Should.Throw(() => S7DateTimeCodec.EncodeDate(new DateTime(1989, 12, 31))); } [Fact] public void DecodeDate_rejects_wrong_buffer_length() { Should.Throw(() => S7DateTimeCodec.DecodeDate(new byte[1])); Should.Throw(() => S7DateTimeCodec.DecodeDate(new byte[3])); } }