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>
337 lines
12 KiB
C#
337 lines
12 KiB
C#
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]));
|
||
}
|
||
}
|