using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Issue #249 — verify ST string read/write round-trips through the driver. The wire format /// (1-word length prefix + 82 ASCII bytes) is owned by libplctag's GetString/ /// SetString; this test fixture pins the driver-level guarantees: /// /// Reads round-trip strings of any length up to the 82-char ST cap. /// Writes longer than 82 chars are rejected with BadOutOfRange at the driver /// level — preventing libplctag from silently truncating. /// Embedded nulls and non-ASCII characters flow through without throwing — the latter /// is libplctag's responsibility to round-trip or degrade. /// Both Slc500 and Plc5 families share the 82-byte ST file convention. /// /// [Trait("Category", "Unit")] public sealed class AbLegacyStringEncodingTests { private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver( AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags) { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)], Tags = tags, }, "drv-1", factory); return (drv, factory); } // ---- Read round-trip ---- [Theory] [InlineData(AbLegacyPlcFamily.Slc500, "")] [InlineData(AbLegacyPlcFamily.Slc500, "Hello")] [InlineData(AbLegacyPlcFamily.Plc5, "Hello")] public async Task Read_returns_string_value_unchanged(AbLegacyPlcFamily family, string value) { var (drv, factory) = NewDriver(family, new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = value }; var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); snapshots.Single().Value.ShouldBe(value); } [Fact] public async Task Read_returns_full_82_char_string_at_ST_capacity() { var full = new string('A', 82); var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500, new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = full }; var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var actual = snapshots.Single().Value.ShouldBeOfType(); actual.Length.ShouldBe(82); actual.ShouldBe(full); } [Fact] public async Task Read_preserves_embedded_null_byte() { // libplctag returns the C-string as the .NET String with whatever bytes the PLC stored. // We assert the driver doesn't strip or truncate at an embedded NUL. var withNull = "AB\0CD"; var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500, new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = withNull }; var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); snapshots.Single().Value.ShouldBe(withNull); } [Fact] public async Task Read_preserves_extended_latin_payload() { // PLC ST files are byte-oriented; non-ASCII passes through whatever round-trip libplctag // applies. The driver itself must not transform. var latin = "café résumé"; var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500, new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = latin }; var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); snapshots.Single().Value.ShouldBe(latin); } // ---- Write round-trip ---- [Theory] [InlineData(AbLegacyPlcFamily.Slc500, "")] [InlineData(AbLegacyPlcFamily.Slc500, "Short msg")] [InlineData(AbLegacyPlcFamily.Slc500, "AB\0CD")] // embedded NUL [InlineData(AbLegacyPlcFamily.Plc5, "Hello PLC5")] public async Task Write_succeeds_and_forwards_string_to_runtime(AbLegacyPlcFamily family, string value) { var (drv, factory) = NewDriver(family, new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Msg", value)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); factory.Tags["ST9:0"].Value.ShouldBe(value); factory.Tags["ST9:0"].WriteCount.ShouldBe(1); } [Fact] public async Task Write_succeeds_for_41_char_mid_length_string() { var mid = new string('M', 41); var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500, new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Msg", mid)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); factory.Tags["ST9:0"].Value.ShouldBe(mid); } [Fact] public async Task Write_succeeds_at_82_char_boundary() { var full = new string('Z', 82); var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500, new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Msg", full)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); ((string)factory.Tags["ST9:0"].Value!).Length.ShouldBe(82); } // ---- Length guard ---- [Theory] [InlineData(AbLegacyPlcFamily.Slc500, 83)] [InlineData(AbLegacyPlcFamily.Slc500, 100)] [InlineData(AbLegacyPlcFamily.Plc5, 200)] public async Task Write_over_82_chars_returns_BadOutOfRange(AbLegacyPlcFamily family, int len) { // The runtime layer (LibplctagLegacyTagRuntime.EncodeValue) rejects with // ArgumentOutOfRangeException; the driver maps that to BadOutOfRange so the OPC UA client // gets a clean failure rather than a silent libplctag truncation. We use the production // runtime for the encode step but stub the I/O via a delegating factory so the test does // not need a real PLC. var oversized = new string('X', len); var factory = new FakeAbLegacyTagFactory { // Reuse the production EncodeValue by routing through a fake that delegates the // length check itself — we model the runtime contract: > 82 chars must throw. Customise = p => new EncodeOnlyLengthCheckingFake(p), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)], Tags = [ new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String), ], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Msg", oversized)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); // The write must NOT have reached libplctag's WriteAsync — guard fires before flush. factory.Tags["ST9:0"].WriteCount.ShouldBe(0); } [Fact] public async Task Write_at_exactly_82_chars_does_not_trip_length_guard() { var atBoundary = new string('B', 82); var factory = new FakeAbLegacyTagFactory { Customise = p => new EncodeOnlyLengthCheckingFake(p), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)], Tags = [ new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String), ], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("Msg", atBoundary)], CancellationToken.None); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); factory.Tags["ST9:0"].WriteCount.ShouldBe(1); } /// /// Test fake that mirrors 's ST length guard so we /// can assert the driver-level mapping (ArgumentOutOfRangeException → BadOutOfRange) /// without instantiating a real libplctag Tag (which would try to open a TCP /// connection in InitializeAsync). /// private sealed class EncodeOnlyLengthCheckingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) { public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) { if (type == AbLegacyDataType.String) { var s = Convert.ToString(value) ?? string.Empty; if (s.Length > 82) throw new ArgumentOutOfRangeException( nameof(value), $"ST string write exceeds 82-byte file element capacity (was {s.Length})."); } base.EncodeValue(type, bitIndex, value); } } }