Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DateTimeCodecTests.cs
Joseph Doherty 2b66cec582 Auto: s7-a3 — DTL/DT/S5TIME/TIME/TOD/DATE codecs
Adds S7DateTimeCodec static class implementing the six Siemens S7 date/time
wire formats:

  - DTL (12 bytes): UInt16 BE year + month/day/dow/h/m/s + UInt32 BE nanos
  - DATE_AND_TIME (8 bytes BCD): yy/mm/dd/hh/mm/ss + 3-digit BCD ms + dow
  - S5TIME (16 bits): 2-bit timebase + 3-digit BCD count → TimeSpan
  - TIME (Int32 ms BE, signed) → TimeSpan, allows negative durations
  - TOD (UInt32 ms BE, 0..86399999) → TimeSpan since midnight
  - DATE (UInt16 BE days since 1990-01-01) → DateTime

Mirrors the S7StringCodec pattern from PR-S7-A2 — codecs operate on raw byte
spans so each format can be locked with golden-byte unit tests without a
live PLC. New S7DataType members (Dtl, DateAndTime, S5Time, Time, TimeOfDay,
Date) are wired into S7Driver.ReadOneAsync/WriteOneAsync via byte-level
ReadBytesAsync/WriteBytesAsync calls — S7.Net's string-keyed Read/Write
overloads have no syntax for these widths.

Uninitialized PLC buffers (all-zero year+month for DTL/DT) reject as
InvalidDataException → BadOutOfRange to operators, rather than decoding as
year-0001 garbage.

S5TIME / TIME / TOD surface as Int32 ms (DriverDataType has no Duration);
DTL / DT / DATE surface as DriverDataType.DateTime.

Test coverage: 30 new golden-vector + round-trip + rejection tests,
including the all-zero buffer rejection paths and BCD-nibble validation.
Build clean, 115/115 S7 tests pass.

Closes #289

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:37:39 -04:00

337 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Golden-byte unit tests for <see cref="S7DateTimeCodec"/>: 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
/// <see cref="S7StringCodecTests"/>.
/// </summary>
[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<byte>(0x07);
bytes[1].ShouldBe<byte>(0xE8);
bytes[2].ShouldBe<byte>(0x01);
bytes[3].ShouldBe<byte>(0x0F);
// 2024-01-15 was a Monday (.NET DayOfWeek=Monday=1); S7 dow = 1+1 = 2.
bytes[4].ShouldBe<byte>(0x02);
bytes[5].ShouldBe<byte>(0x0C);
bytes[6].ShouldBe<byte>(0x22);
bytes[7].ShouldBe<byte>(0x38);
bytes[8].ShouldBe<byte>(0x00);
bytes[9].ShouldBe<byte>(0x00);
bytes[10].ShouldBe<byte>(0x00);
bytes[11].ShouldBe<byte>(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<InvalidDataException>(() => 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<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf));
// Month 13 — invalid.
var buf2 = new byte[] { 0x07, 0xE8, 13, 1, 1, 0, 0, 0, 0, 0, 0, 0 };
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf2));
}
[Fact]
public void DecodeDtl_rejects_wrong_buffer_length()
{
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(new byte[11]));
}
[Fact]
public void EncodeDtl_rejects_year_outside_spec()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
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<byte>(0x24);
bytes[1].ShouldBe<byte>(0x01);
bytes[2].ShouldBe<byte>(0x15);
bytes[3].ShouldBe<byte>(0x12);
bytes[4].ShouldBe<byte>(0x34);
bytes[5].ShouldBe<byte>(0x56);
bytes[6].ShouldBe<byte>(0x78);
bytes[7].ShouldBe<byte>(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<byte>(0x95);
S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt);
}
[Fact]
public void DecodeDt_rejects_all_zero_uninitialized_buffer()
{
Should.Throw<InvalidDataException>(() => 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<InvalidDataException>(() => S7DateTimeCodec.DecodeDt(buf));
}
[Fact]
public void EncodeDt_rejects_year_outside_1990_to_2089_window()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDt(new DateTime(1989, 12, 31)));
Should.Throw<ArgumentOutOfRangeException>(() =>
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<byte>(0x01);
bytes[1].ShouldBe<byte>(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<byte>(0x39);
bytes[1].ShouldBe<byte>(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<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(-1)));
Should.Throw<ArgumentOutOfRangeException>(() =>
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<ArgumentException>(() =>
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<InvalidDataException>(() => 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<ArgumentOutOfRangeException>(() =>
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<InvalidDataException>(() => S7DateTimeCodec.DecodeTod(buf));
}
[Fact]
public void EncodeTod_rejects_negative_and_overflow()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeTod(TimeSpan.FromMilliseconds(-1)));
Should.Throw<ArgumentOutOfRangeException>(() =>
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<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDate(new DateTime(1989, 12, 31)));
}
[Fact]
public void DecodeDate_rejects_wrong_buffer_length()
{
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[1]));
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[3]));
}
}