diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index a7056144..6969602f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -499,6 +499,14 @@ public sealed class S7Driver if (tag.ArrayCount is >= 1) return await ReadArrayAsync(plc, tag, addr, ct).ConfigureAwait(false); + // Wide/structured scalar path (Int64/UInt64/Float64/String/DateTime): S7.Net's string + // ReadAsync only decodes 1/2/4-byte size suffixes, so these can't go through the narrow + // path below. Read a contiguous byte block at the start byte and decode it big-endian — + // mirrors the array path's buffer read. Timer/Counter are excluded here (IsBufferType + // gates on Area) and keep using the narrow path for now (broadened in a follow-up). + if (IsBufferType(tag, addr)) + return await ReadScalarBlockAsync(plc, tag, addr, ct).ConfigureAwait(false); + // S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on // the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum // specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below @@ -536,6 +544,134 @@ public sealed class S7Driver return DecodeArrayBlock(tag, addr, block); } + /// + /// True when this tag must be read/written through the byte-buffer codec rather than + /// S7.Net's string path. The wide/structured types (Int64/UInt64/Float64/String/DateTime) + /// are byte-anchored (DBB/MB/IB/QB) and decoded from a + /// contiguous block — S7.Net's string ReadAsync only understands 1/2/4-byte size + /// suffixes, so they can't use the narrow path. Timer/Counter areas are deliberately + /// excluded (they still route through the narrow path for now); a follow-up adds them to + /// this seam. The init guard already enforces byte-addressing + the Timer→Float64 / + /// Counter→Int32 type constraints, so by the time a tag reaches here it is well-formed. + /// + /// Tag definition carrying the . + /// Parsed address — its excludes Timer/Counter from the seam. + /// true if the tag routes through the byte-buffer codec. + private static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) => + addr.Area is not S7Area.Timer and not S7Area.Counter + && tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 + or S7DataType.String or S7DataType.DateTime; + + /// + /// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's + /// buffer-based Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct) (a single + /// PLC round-trip), then hands the raw block to the pure + /// decode. Mirrors 's shape. + /// + private async Task ReadScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct) + { + var width = ScalarByteWidth(tag); + var block = await plc.ReadBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, width, ct) + .ConfigureAwait(false) + ?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'"); + + return DecodeScalarBlock(tag, addr, block); + } + + /// + /// Byte width of one wide/structured scalar. Int64/UInt64/Float64/DateTime are 8 bytes; + /// an S7 STRING occupies StringLength + 2 bytes (the two-byte header carries the + /// declared max length and the current actual length). Timer/Counter widths land in a + /// follow-up. Throws for any non-buffer type — defensive; gates + /// this so a non-buffer type never reaches here. + /// + /// Tag definition carrying the and (for strings) StringLength. + /// The byte width to read/write for this scalar. + internal static int ScalarByteWidth(S7TagDefinition tag) => tag.DataType switch + { + S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8, + S7DataType.DateTime => 8, + S7DataType.String => tag.StringLength + 2, + _ => throw new InvalidOperationException( + $"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"), + }; + + /// + /// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR + /// value (long / ulong / double), boxed as . No + /// network I/O — factored out of so the codec is + /// unit-testable against a known block without a live PLC (S7.Net ships no in-process + /// fake). Mirrors . + /// + /// Tag definition carrying the wide . + /// Parsed address (carried for the error surface + the Timer/Counter seam). + /// Raw contiguous byte block read from the PLC (length == ). + /// The decoded scalar value boxed as . + internal static object DecodeScalarBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block) + { + // Timer/Counter decode added in a follow-up — a branch on addr.Area prepends here. + // Each numeric arm is boxed to object explicitly: a bare switch expression would unify + // long/ulong/double to their common type (double) and box THAT, mis-typing Int64/UInt64. + return tag.DataType switch + { + S7DataType.Int64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(block), + S7DataType.UInt64 => System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block), + S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block), + + S7DataType.String => throw new NotSupportedException( + "S7 String scalar reads land in a follow-up PR"), + S7DataType.DateTime => throw new NotSupportedException( + "S7 DateTime scalar reads land in a follow-up PR"), + + _ => throw new System.IO.InvalidDataException( + $"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " + + $"'{tag.Address}' parsed as Size={addr.Size}"), + }; + } + + /// + /// Pure encode step — turns the caller's value into the raw S7 (big-endian) byte block + /// for a wide scalar write. No network I/O — factored out of + /// so the codec is unit-testable (mirrors + /// ). + /// + /// Tag definition carrying the wide . + /// Value to encode (coerced via Convert.To*). + /// The big-endian byte block to write. + internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value) + { + switch (tag.DataType) + { + case S7DataType.Int64: + { + var b = new byte[8]; + System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(b, Convert.ToInt64(value)); + return b; + } + case S7DataType.UInt64: + { + var b = new byte[8]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt64BigEndian(b, Convert.ToUInt64(value)); + return b; + } + case S7DataType.Float64: + { + var b = new byte[8]; + System.Buffers.Binary.BinaryPrimitives.WriteDoubleBigEndian(b, Convert.ToDouble(value)); + return b; + } + + case S7DataType.String: + throw new NotSupportedException("S7 String scalar writes land in a follow-up PR"); + case S7DataType.DateTime: + throw new NotSupportedException("S7 DateTime scalar writes land in a follow-up PR"); + + default: + throw new InvalidOperationException( + $"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')"); + } + } + /// Width in bytes of one array element for the given access size. Bit elements are /// byte-granular over the wire (one byte per bool), so they cost 1 byte each. /// The parsed access width. @@ -770,6 +906,23 @@ public sealed class S7Driver private async Task WriteOneAsync(Plc plc, S7TagDefinition tag, object? value, CancellationToken ct) { + // Parse the address the same way ReadOneAsync does: authored tags pre-parse at init + // (_parsedByName); an equipment-tag ref (resolved transiently) parses on demand. Needed + // here so the wide-type write can byte-address the block (the narrow path below addresses + // by the raw address string instead). + var addr = _parsedByName.TryGetValue(tag.Name, out var parsed) + ? parsed + : S7AddressParser.Parse(tag.Address); + + // Wide/structured scalar path: encode the value to a big-endian byte block and write it + // at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam; + // the narrow string-based write below stays unchanged for 1/2/4-byte types. + if (IsBufferType(tag, addr)) + { + await WriteScalarBlockAsync(plc, tag, addr, value, ct).ConfigureAwait(false); + return; + } + // S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to // match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. // Our S7DataType lets the caller pass short/int/float; convert to the unsigned @@ -778,6 +931,19 @@ public sealed class S7Driver await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false); } + /// + /// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's + /// buffer-based Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct). + /// The pure produces the big-endian bytes; this method + /// owns only the network I/O (mirrors ). + /// + private async Task WriteScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct) + { + var bytes = EncodeScalarBlock(tag, value); + await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, bytes, ct) + .ConfigureAwait(false); + } + /// /// Pure boxing step — converts the caller's value into the unsigned wire type that /// S7.Net's Plc.WriteAsync expects for each address size (bool → bool, byte diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs index 90575033..7e54b178 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs @@ -171,23 +171,7 @@ public sealed class S7ArrayReadTests // ── Discovery — IsArray / ArrayDim flip ─────────────────────────────────────────────── - private sealed class RecordingBuilder : IAddressSpaceBuilder - { - public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new(); - public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attr) - { - Variables.Add((browseName, attr)); - return new Handle(); - } - public void AddProperty(string browseName, DriverDataType dataType, object? value) { } - public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } - private sealed class Handle : IVariableHandle - { - public string FullReference => "stub"; - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException(); - } - } + // RecordingAddressSpaceBuilder lives in S7TestBuilders.cs — shared across the S7 test suite. /// Verifies an array tag is discovered with IsArray=true and ArrayDim=count. [Fact] @@ -204,7 +188,7 @@ public sealed class S7ArrayReadTests }; using var drv = new S7Driver(opts, "s7-arr-disco"); - var builder = new RecordingBuilder(); + var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); var scalar = builder.Variables.Single(v => v.Name == "Scalar").Attr; @@ -229,7 +213,7 @@ public sealed class S7ArrayReadTests }; using var drv = new S7Driver(opts, "s7-arr-one"); - var builder = new RecordingBuilder(); + var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); var one = builder.Variables.Single().Attr; @@ -248,7 +232,7 @@ public sealed class S7ArrayReadTests }; using var drv = new S7Driver(opts, "s7-arr-null"); - var builder = new RecordingBuilder(); + var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); var scalar = builder.Variables.Single().Attr; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs index a437e46f..76bfe731 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DiscoveryAndSubscribeTests.cs @@ -13,56 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; [Trait("Category", "Unit")] public sealed class S7DiscoveryAndSubscribeTests { - private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder - { - public readonly List Folders = new(); - public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new(); - - /// Adds a folder to the address space. - /// The browse name of the folder. - /// The display name of the folder. - /// This builder instance for method chaining. - public IAddressSpaceBuilder Folder(string browseName, string displayName) - { - Folders.Add(browseName); - return this; - } - - /// Adds a variable to the address space. - /// The browse name of the variable. - /// The display name of the variable. - /// The attribute information for the variable. - /// A handle to the created variable. - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - Variables.Add((browseName, attributeInfo)); - return new StubHandle(); - } - - /// Adds a property to a variable. - /// The browse name of the property. - /// The data type of the property. - /// The initial value of the property. - public void AddProperty(string browseName, DriverDataType dataType, object? value) { } - - /// Attaches an alarm condition to a variable. - /// The variable to attach the alarm to. - /// The name of the alarm. - /// The alarm information. - public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } - - private sealed class StubHandle : IVariableHandle - { - /// Gets the full reference of the variable. - public string FullReference => "stub"; - - /// Marks this variable as an alarm condition. - /// The alarm condition information. - /// An alarm condition sink. - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) - => throw new NotImplementedException("S7 driver never calls this — no alarm surfacing"); - } - } + // RecordingAddressSpaceBuilder lives in S7TestBuilders.cs — shared across the S7 test suite. /// Verifies that DiscoverAsync projects every configured tag into the address space. [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs index fe6e8c74..647b13e1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs @@ -7,8 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Regression tests for the remaining code-review findings closed against the S7 driver: /// Driver.S7-003 (Read/WriteAsync null-arg validation), Driver.S7-009 (poll-loop health -/// update + backoff), Driver.S7-010 (Dispose without sync-over-async), and Driver.S7-013 -/// (reject not-yet-implemented S7DataType values at init). +/// update + backoff), Driver.S7-010 (Dispose without sync-over-async), and the Phase 4d +/// guard-(b) (wide/structured types must be byte-addressed). The wide types are no longer +/// rejected wholesale at init — the 8-byte numerics round-trip through the byte-buffer codec +/// (see ); only a wide type at a non-byte address is rejected. /// [Trait("Category", "Unit")] public sealed class S7DriverCodeReviewFixTests2 @@ -136,31 +138,36 @@ public sealed class S7DriverCodeReviewFixTests2 Should.NotThrow(() => drv.Dispose()); } - // ── Driver.S7-013 — Reject not-yet-implemented S7DataType values at init ───────────── + // ── Phase 4d guard-(b) — wide/structured types must be byte-addressed ──────────────── - /// Verifies that Initialize rejects not-yet-implemented data types with NotSupportedException. - /// The S7 data type that is not yet implemented. + /// + /// Verifies the init guard rejects a wide/structured type authored at a non-byte address + /// (here DB1.DBD0, a DWord). Phase 4d wired the 8-byte numerics (Int64/UInt64/ + /// Float64) through the byte-buffer codec — see for the + /// round-trip coverage — but they (and the still-deferred String/DateTime) decode from a + /// byte-anchored block (DBB/MB/IB/QB). A non-byte suffix would + /// mis-frame the value, so RejectUnsupportedTagConfigs guard-(b) fails the config + /// fast at init rather than as a misleading per-read fault. + /// + /// A wide/structured S7 data type authored at a non-byte address. [Theory] [InlineData(S7DataType.Int64)] [InlineData(S7DataType.UInt64)] [InlineData(S7DataType.Float64)] [InlineData(S7DataType.String)] [InlineData(S7DataType.DateTime)] - public async Task Initialize_rejects_not_yet_implemented_data_type_with_NotSupportedException(S7DataType dt) + public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt) { - // A tag declared with one of the not-yet-wired data types parses cleanly and creates - // an OPC UA node via DiscoverAsync — then every Read/Write of it returns BadNotSupported. - // The half-implemented type must be rejected at init so a site can't deploy a config - // that produces dead nodes (Driver.S7-013). var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250), - // Use a DB.DBD address — the parser accepts it for every data type. The init guard - // must fault on the data-type rather than on the address. + // DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape + // for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip; + // see S7ScalarBlockTests.) Tags = [new S7TagDefinition("X", "DB1.DBD0", dt)], }; - using var drv = new S7Driver(opts, $"s7-bad-dt-{dt}"); + using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}"); var ex = await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs new file mode 100644 index 00000000..84aaba7a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs @@ -0,0 +1,191 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Unit tests for the S7 wide-type (8-byte numeric) byte-buffer codec: the pure +/// / +/// helpers and . These decode/encode an +/// Int64/UInt64/LReal (Float64) scalar from a contiguous big-endian byte block — the +/// network I/O half (Plc.ReadBytesAsync/WriteBytesAsync) has no in-process +/// fake so only the codec is unit-proven (mirrors ). +/// String/DateTime decode is a deferred stub here (T3/T4); this file pins the +/// NotSupportedException contract those land against. +/// +[Trait("Category", "Unit")] +public sealed class S7ScalarBlockTests +{ + // ── Helpers ────────────────────────────────────────────────────────────────────────── + + // Wide scalars are byte-anchored: DB{n}.DBB{offset}, parser yields S7Size.Byte. + private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254) => + new("WideTag", "DB1.DBB0", dt, StringLength: stringLength); + + private static S7ParsedAddress Addr() => + new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0); + + // S7 is big-endian: most-significant byte first. + private static byte[] BeUInt64(ulong v) + { + var b = new byte[8]; + for (var i = 0; i < 8; i++) + b[i] = (byte)(v >> (56 - i * 8)); + return b; + } + + // ── ScalarByteWidth ─────────────────────────────────────────────────────────────────── + + /// Verifies the 8-byte numeric widths and the String/DateTime widths. + [Theory] + [InlineData(S7DataType.Int64, 8)] + [InlineData(S7DataType.UInt64, 8)] + [InlineData(S7DataType.Float64, 8)] + [InlineData(S7DataType.DateTime, 8)] + public void ScalarByteWidth_fixed_width_types(S7DataType dt, int expected) + => S7Driver.ScalarByteWidth(Tag(dt)).ShouldBe(expected); + + /// Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len). + [Fact] + public void ScalarByteWidth_String_is_length_plus_two() + => S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10)).ShouldBe(12); + + // ── DecodeScalarBlock — Int64 ───────────────────────────────────────────────────────── + + /// Verifies an Int64 block decodes from big-endian bytes. + [Fact] + public void DecodeScalarBlock_Int64_reads_big_endian() + { + var block = BeUInt64(0x0123456789ABCDEFUL); + // First byte is the most-significant byte (0x01) — proves big-endian, not little-endian. + block[0].ShouldBe((byte)0x01); + + var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block); + result.ShouldBeOfType().ShouldBe(0x0123456789ABCDEFL); + } + + /// Verifies a negative Int64 decodes correctly (two's complement, high bit set). + [Fact] + public void DecodeScalarBlock_Int64_negative() + { + var block = BeUInt64(unchecked((ulong)-2L)); // 0xFFFF_FFFF_FFFF_FFFE + var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block); + result.ShouldBeOfType().ShouldBe(-2L); + } + + // ── DecodeScalarBlock — UInt64 ──────────────────────────────────────────────────────── + + /// Verifies a UInt64 block decodes a value larger than long.MaxValue. + [Fact] + public void DecodeScalarBlock_UInt64_reads_value_above_long_max() + { + var block = BeUInt64(ulong.MaxValue); // 0xFFFF_FFFF_FFFF_FFFF + var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.UInt64), Addr(), block); + result.ShouldBeOfType().ShouldBe(ulong.MaxValue); + } + + // ── DecodeScalarBlock — Float64 (LReal) ─────────────────────────────────────────────── + + /// Verifies a Float64 (LReal) block decodes from IEEE-754 big-endian. + [Fact] + public void DecodeScalarBlock_Float64_reads_ieee754_big_endian() + { + var bits = unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI)); + var block = BeUInt64(bits); + var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Float64), Addr(), block); + result.ShouldBeOfType().ShouldBe(Math.PI, tolerance: 1e-12); + } + + // ── EncodeScalarBlock — big-endian byte production ──────────────────────────────────── + + /// Verifies Int64 encodes to big-endian bytes (MSB first). + [Fact] + public void EncodeScalarBlock_Int64_writes_big_endian() + { + var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Int64), 0x0123456789ABCDEFL); + bytes.Length.ShouldBe(8); + bytes.ShouldBe(BeUInt64(0x0123456789ABCDEFUL)); + bytes[0].ShouldBe((byte)0x01); // MSB first — little-endian regression guard. + } + + /// Verifies UInt64 encodes to big-endian bytes. + [Fact] + public void EncodeScalarBlock_UInt64_writes_big_endian() + { + var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.UInt64), ulong.MaxValue); + bytes.ShouldBe(BeUInt64(ulong.MaxValue)); + } + + /// Verifies Float64 encodes to IEEE-754 big-endian bytes. + [Fact] + public void EncodeScalarBlock_Float64_writes_ieee754_big_endian() + { + var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Float64), Math.PI); + bytes.ShouldBe(BeUInt64(unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI)))); + } + + // ── Round-trip identity (encode → decode) ───────────────────────────────────────────── + + /// Verifies Int64 round-trips through encode→decode for positive, negative and edge values. + [Theory] + [InlineData(0L)] + [InlineData(1L)] + [InlineData(-1L)] + [InlineData(long.MaxValue)] + [InlineData(long.MinValue)] + [InlineData(0x0123456789ABCDEFL)] + public void Int64_round_trips(long value) + { + var tag = Tag(S7DataType.Int64); + var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); + decoded.ShouldBeOfType().ShouldBe(value); + } + + /// Verifies UInt64 round-trips, including a large value above long.MaxValue. + [Theory] + [InlineData(0UL)] + [InlineData(70_000UL)] + [InlineData(ulong.MaxValue)] + [InlineData(0x8000_0000_0000_0001UL)] + public void UInt64_round_trips(ulong value) + { + var tag = Tag(S7DataType.UInt64); + var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); + decoded.ShouldBeOfType().ShouldBe(value); + } + + /// Verifies Float64 (LReal) round-trips for representative doubles. + [Theory] + [InlineData(0.0)] + [InlineData(3.141592653589793)] + [InlineData(-2.5e-300)] + [InlineData(1.7976931348623157e308)] + public void Float64_round_trips(double value) + { + var tag = Tag(S7DataType.Float64); + var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); + decoded.ShouldBeOfType().ShouldBe(value); + } + + // ── Deferred-stub contract: String/DateTime throw NotSupportedException ──────────────── + + /// Verifies String/DateTime decode is a deferred stub (NotSupportedException — T3/T4). + /// The not-yet-implemented wide type. + [Theory] + [InlineData(S7DataType.String)] + [InlineData(S7DataType.DateTime)] + public void DecodeScalarBlock_String_or_DateTime_throws_NotSupported(S7DataType dt) + { + var tag = Tag(dt, stringLength: 10); + var block = new byte[S7Driver.ScalarByteWidth(tag)]; + Should.Throw(() => S7Driver.DecodeScalarBlock(tag, Addr(), block)); + } + + /// Verifies String/DateTime encode is a deferred stub (NotSupportedException — T3/T4). + /// The not-yet-implemented wide type. + [Theory] + [InlineData(S7DataType.String)] + [InlineData(S7DataType.DateTime)] + public void EncodeScalarBlock_String_or_DateTime_throws_NotSupported(S7DataType dt) + => Should.Throw(() => S7Driver.EncodeScalarBlock(Tag(dt), "x")); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs new file mode 100644 index 00000000..7fda1f8e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TestBuilders.cs @@ -0,0 +1,65 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Shared in-memory recorder used across the S7 driver +/// test suite. Captures every folder + variable the driver projects via +/// so a test can assert the discovered shape without a +/// live address space. Replaces the three structurally-identical per-file recorders +/// (RecordingAddressSpaceBuilder/RecordingBuilder/CapturingBuilder) that +/// previously duplicated this code. +/// +internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder +{ + /// Browse names of every folder the driver created, in order. + public readonly List Folders = new(); + + /// Every variable the driver created, as (browse name, attribute info) pairs. + public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new(); + + /// Records the folder browse name and returns this builder for chaining. + /// The browse name of the folder. + /// The display name of the folder. + /// This builder instance for method chaining. + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + Folders.Add(browseName); + return this; + } + + /// Records the variable's browse name + attribute info. + /// The browse name of the variable. + /// The display name of the variable. + /// The attribute information for the variable. + /// A stub handle. + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add((browseName, attributeInfo)); + return new StubHandle(); + } + + /// No-op property sink — the S7 driver does not add properties. + /// The browse name of the property. + /// The data type of the property. + /// The initial value of the property. + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + /// No-op alarm sink — the S7 driver never surfaces alarms. + /// The variable to attach the alarm to. + /// The name of the alarm. + /// The alarm information. + public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } + + private sealed class StubHandle : IVariableHandle + { + /// Gets the full reference of the variable. + public string FullReference => "stub"; + + /// Marks this variable as an alarm condition. + /// The alarm condition information. + /// An alarm condition sink. + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) + => throw new NotImplementedException("S7 driver never calls this — no alarm surfacing"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs index 81f45ba0..85db687e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TypeMappingTests.cs @@ -194,49 +194,10 @@ public sealed class S7TypeMappingTests // ── MapDataType (via DiscoverAsync) — Int64/UInt64 now map to their own members ─────── - // MapDataType is private; reach it through DiscoverAsync with a capturing builder — the - // same indirection S7DiscoveryAndSubscribeTests uses. T1 split the formerly-lossy - // Int64/UInt64 → Int32 line so 64-bit tags surface as the matching DriverDataType. - - private sealed class CapturingBuilder : IAddressSpaceBuilder - { - public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new(); - - /// Records the folder and returns this builder for chaining. - /// The browse name of the folder. - /// The display name of the folder. - /// This builder instance. - public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; - - /// Records the variable's name + attribute info. - /// The browse name of the variable. - /// The display name of the variable. - /// The attribute information for the variable. - /// A stub handle. - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - Variables.Add((browseName, attributeInfo)); - return new StubHandle(); - } - - /// No-op property sink. - /// The browse name of the property. - /// The data type of the property. - /// The initial value of the property. - public void AddProperty(string browseName, DriverDataType dataType, object? value) { } - - private sealed class StubHandle : IVariableHandle - { - /// Gets the full reference of the variable. - public string FullReference => "stub"; - - /// Marks this variable as an alarm condition. - /// The alarm condition information. - /// An alarm condition sink. - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) - => throw new NotImplementedException("S7 driver never calls this"); - } - } + // MapDataType is private; reach it through DiscoverAsync with the shared + // RecordingAddressSpaceBuilder (S7TestBuilders.cs) — the same indirection + // S7DiscoveryAndSubscribeTests uses. T1 split the formerly-lossy Int64/UInt64 → Int32 line + // so 64-bit tags surface as the matching DriverDataType. /// Verifies that an Int64 tag discovers a node with DriverDataType.Int64 (no longer lossily mapped to Int32). [Fact] @@ -249,7 +210,7 @@ public sealed class S7TypeMappingTests }; using var drv = new S7Driver(opts, "s7-i64-map"); - var builder = new CapturingBuilder(); + var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); builder.Variables.Single(v => v.Name == "Counter64").Attr.DriverDataType @@ -267,7 +228,7 @@ public sealed class S7TypeMappingTests }; using var drv = new S7Driver(opts, "s7-u64-map"); - var builder = new CapturingBuilder(); + var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); builder.Variables.Single(v => v.Name == "Total64").Attr.DriverDataType