using System.Runtime.CompilerServices; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Gitea #611 — writes to controller-discovered UDT members (item 2) and the whole-UDT refusal (item 3). /// A discovered member becomes writable the way every v3 tag does: browse-commit authors it as a raw tag /// (tagPath = the member path, dataType = the template's atomic type, AccessLevel Read — see /// RawBrowseCommitMapperTests.A_discovered_AbCip_UDT_member_commits_read_only_with_its_member_path_and_type) /// and the operator flips it to ReadWrite. These tests start from what discovery emits and assert what the /// driver does with that tag. /// [Trait("Category", "Unit")] public sealed class AbCipDiscoveredMemberWriteTests { private const string Device = "ab://10.0.0.5/1,0"; /// /// Discovery → commit → write, end to end at the driver seam: every atomic leaf of a browsed UDT /// (top-level, nested, BIT) committed as a raw tag writes through a runtime named by its member path, /// encoded as its browsed type. /// [Fact] public async Task Every_browsed_member_leaf_committed_as_a_raw_tag_writes_to_its_own_member_path() { var leaves = await DiscoverLeavesAsync(); leaves.Select(l => l.FullName).ShouldBe( ["Seg.ID", "Seg.Enable", "Seg.RampTime", "Seg.Sub.Code"], ignoreOrder: true); var factory = new FakeAbCipTagFactory(); var drv = await StartAsync(factory, [.. leaves.Select(l => Committed(l.FullName, l.NativeDataType!))]); var values = new Dictionary { ["Seg.ID"] = 42, ["Seg.Enable"] = true, ["Seg.RampTime"] = 1.5f, ["Seg.Sub.Code"] = (short)7, }; var results = await drv.WriteAsync( [.. values.Select(kv => new WriteRequest(kv.Key, kv.Value))], CancellationToken.None); results.Select(r => r.StatusCode).ShouldAllBe(s => s == AbCipStatusMapper.Good); foreach (var (path, value) in values) { var tag = factory.Tags[path]; tag.CreationParams.TagName.ShouldBe(path); // the member path itself — never its UDT, never a hidden host tag.Value.ShouldBe(value); tag.WriteCount.ShouldBe(1); } factory.Tags["Seg.Enable"].CreationParams.AuthoredType.ShouldBe(AbCipDataType.Bool); factory.Tags["Seg.Sub.Code"].CreationParams.AuthoredType.ShouldBe(AbCipDataType.Int); } /// The operator's writable:false on a committed member is honoured — nothing is created or sent. [Fact] public async Task A_member_authored_not_writable_is_BadNotWritable() { var factory = new FakeAbCipTagFactory(); var drv = await StartAsync(factory, Committed("Seg.ID", "DInt", writable: false)); (await drv.WriteAsync([new WriteRequest("Seg.ID", 1)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); factory.Tags.ShouldNotContainKey("Seg.ID"); } /// /// An INT authored over a member the controller holds as 4 bytes: libplctag would patch the low two bytes /// of its read buffer and send all four. Refused BadTypeMismatch before anything is sent, handle kept, /// health untouched. /// [Fact] public async Task A_member_authored_narrower_than_the_controller_value_is_refused_before_sending() { var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 4, Value = 0 } }; // The mistyped tag cannot prime health itself — its READ is refused for the same width mismatch. var drv = await StartAsync(factory, Committed("Seg.ID", "Int"), Committed("Seg.RampTime", "Real")); await PrimeHealthAsync(drv, "Seg.RampTime"); var health = drv.GetHealth(); (await drv.WriteAsync([new WriteRequest("Seg.ID", (short)5)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); factory.Tags["Seg.ID"].WriteCount.ShouldBe(0); factory.Tags["Seg.ID"].Disposed.ShouldBeFalse(); drv.GetHealth().ShouldBe(health); } /// /// A BOOL authored over a 4-byte value (a packed BOOL-array DWORD, or a mis-typed DINT member): writing it /// would rewrite all 32 bits. Refused BadNotSupported — the native runtime's refusal — before sending. /// [Fact] public async Task A_BOOL_over_a_wider_value_is_refused_NotSupported_before_sending() { var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 4, Value = 0 } }; var drv = await StartAsync(factory, Committed("Seg.Flags", "Bool")); await PrimeHealthAsync(drv, "Seg.Flags"); var health = drv.GetHealth(); (await drv.WriteAsync([new WriteRequest("Seg.Flags", true)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); factory.Tags["Seg.Flags"].WriteCount.ShouldBe(0); drv.GetHealth().ShouldBe(health); } /// A BIT member reads back as one byte (type 0xC1), so a BOOL write to it goes out. [Fact] public async Task A_BOOL_member_held_as_one_byte_writes() { var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 1, Value = false } }; var drv = await StartAsync(factory, Committed("Seg.Enable", "Bool")); (await drv.WriteAsync([new WriteRequest("Seg.Enable", true)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.Tags["Seg.Enable"].WriteCount.ShouldBe(1); } /// Gitea #611 item 3 — a whole-UDT write is refused by decision: no runtime, nothing sent, no health change. [Fact] public async Task A_whole_UDT_write_is_refused_NotSupported_without_a_runtime_or_a_health_change() { var factory = new FakeAbCipTagFactory(); var drv = await StartAsync(factory, Committed("Seg", "Structure"), Committed("Seg.ID", "DInt")); await PrimeHealthAsync(drv, "Seg.ID"); var health = drv.GetHealth(); (await drv.WriteAsync([new WriteRequest("Seg", new byte[8])], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); factory.Tags.ShouldNotContainKey("Seg"); drv.GetHealth().ShouldBe(health); } /// /// A runtime's NotSupportedException is a local refusal the device never saw — BadNotSupported with the /// handle kept and health untouched (it used to latch Degraded). /// [Fact] public async Task A_runtime_NotSupported_refusal_leaves_health_alone_and_keeps_the_handle() { var factory = new FakeAbCipTagFactory { Customise = p => new RefusingEncodeTag(p) { Value = 0 } }; var drv = await StartAsync(factory, Committed("Seg.ID", "DInt")); await PrimeHealthAsync(drv, "Seg.ID"); var health = drv.GetHealth(); (await drv.WriteAsync([new WriteRequest("Seg.ID", 1)], CancellationToken.None)) .Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); drv.GetHealth().ShouldBe(health); factory.Tags["Seg.ID"].Disposed.ShouldBeFalse(); factory.Tags["Seg.ID"].WriteCount.ShouldBe(0); } /// /// Review L4 — directly, with a real size so the check runs: /// every atomic width (1/2/4/8, incl. LINT/ULINT/LREAL) passes at its own size and is refused at another; /// arrays, Tag.N bit selectors, STRING/DT and an unknown size (<= 0) are never judged. /// [Theory] // matching widths write [InlineData(AbCipDataType.Bool, 1, null)] [InlineData(AbCipDataType.SInt, 1, null)] [InlineData(AbCipDataType.USInt, 1, null)] [InlineData(AbCipDataType.Int, 2, null)] [InlineData(AbCipDataType.UInt, 2, null)] [InlineData(AbCipDataType.DInt, 4, null)] [InlineData(AbCipDataType.UDInt, 4, null)] [InlineData(AbCipDataType.Real, 4, null)] [InlineData(AbCipDataType.LInt, 8, null)] [InlineData(AbCipDataType.ULInt, 8, null)] [InlineData(AbCipDataType.LReal, 8, null)] // mismatches are refused [InlineData(AbCipDataType.LInt, 4, AbCipStatusMapper.BadTypeMismatch)] [InlineData(AbCipDataType.LReal, 4, AbCipStatusMapper.BadTypeMismatch)] [InlineData(AbCipDataType.ULInt, 2, AbCipStatusMapper.BadTypeMismatch)] [InlineData(AbCipDataType.Real, 8, AbCipStatusMapper.BadTypeMismatch)] [InlineData(AbCipDataType.Int, 4, AbCipStatusMapper.BadTypeMismatch)] [InlineData(AbCipDataType.SInt, 2, AbCipStatusMapper.BadTypeMismatch)] [InlineData(AbCipDataType.Bool, 4, AbCipStatusMapper.BadNotSupported)] // types with no fixed encoded width are never judged [InlineData(AbCipDataType.String, 88, null)] [InlineData(AbCipDataType.String, 4, null)] [InlineData(AbCipDataType.Dt, 8, null)] // unknown size is never judged [InlineData(AbCipDataType.DInt, 0, null)] [InlineData(AbCipDataType.DInt, -1, null)] public void WriteWidthRefusal_judges_scalar_widths_only(AbCipDataType type, int size, uint? expected) { var def = new AbCipTagDefinition("T", Device, "Seg.X", type); AbCipDriver.WriteWidthRefusal(def, bitIndex: null, size).ShouldBe(expected); } /// Review L4 — array tags and bit selectors are exempt even at a size that would otherwise refuse. [Fact] public void WriteWidthRefusal_exempts_arrays_and_bit_selectors() { // Control: the same scalar at the same size IS refused, so the exemptions below are not vacuous. AbCipDriver.WriteWidthRefusal(new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int), null, 4) .ShouldBe(AbCipStatusMapper.BadTypeMismatch); AbCipDriver.WriteWidthRefusal( new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int, ElementCount: 4, IsArray: true), null, 4) .ShouldBeNull(); AbCipDriver.WriteWidthRefusal( new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int, ElementCount: 2), null, 4) .ShouldBeNull(); AbCipDriver.WriteWidthRefusal( new AbCipTagDefinition("T", Device, "Flags.3", AbCipDataType.Bool), bitIndex: 3, size: 4) .ShouldBeNull(); AbCipDriver.WriteWidthRefusal( new AbCipTagDefinition("T", Device, "Word.3", AbCipDataType.Int), bitIndex: 3, size: 4) .ShouldBeNull(); } /// /// Review L2 — a runtime that types BOOL writes itself (native) is left to judge a BOOL over a wider value; /// only libplctag, which sees a size and nothing else, gets the pre-check's BadNotSupported. /// [Fact] public void WriteWidthRefusal_leaves_a_wide_BOOL_to_a_runtime_that_sees_the_CIP_type() { var def = new AbCipTagDefinition("T", Device, "Seg.Flag", AbCipDataType.Bool); AbCipDriver.WriteWidthRefusal(def, null, 4, runtimeTypesBool: false).ShouldBe(AbCipStatusMapper.BadNotSupported); AbCipDriver.WriteWidthRefusal(def, null, 4, runtimeTypesBool: true).ShouldBeNull(); // Non-BOOL mismatches are still refused up front on every runtime. AbCipDriver.WriteWidthRefusal(new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int), null, 4, runtimeTypesBool: true).ShouldBe(AbCipStatusMapper.BadTypeMismatch); } /// /// Review L2, end to end on the native runtime: a BOOL authored over a mistyped DINT member writes /// BadTypeMismatch (the same verdict as its read); a BOOL over a packed BOOL-array DWORD writes /// BadNotSupported. Neither sends a write; health is untouched. /// [Theory] [InlineData("C400", AbCipStatusMapper.BadTypeMismatch)] // DINT member mistyped as Bool [InlineData("D300", AbCipStatusMapper.BadNotSupported)] // packed BOOL-array DWORD public async Task A_native_BOOL_over_a_wider_value_keeps_its_type_distinction(string typeHex, uint expected) { var channels = new List(); var factory = new NativeFactory(p => { var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000000" + typeHex + "00000000")) }; lock (channels) channels.Add(ch); return NativeAbCipTagRuntime.ForTest(ch, p); }); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = [Committed("Seg.Flag", "Bool")], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-native", factory); await drv.InitializeAsync("{}", CancellationToken.None); var health = drv.GetHealth(); (await drv.WriteAsync([new WriteRequest("Seg.Flag", true)], CancellationToken.None)) .Single().StatusCode.ShouldBe(expected); channels.ShouldHaveSingleItem().Sent.ShouldAllBe(req => req[0] != 0x4D); // no Write Tag request drv.GetHealth().ShouldBe(health); } private sealed class NativeFactory(Func create) : IAbCipTagFactory { public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams); } // ---- helpers --------------------------------------------------------------------------------------- /// Browses a UDT shaped like the Z-35162 press's UDT_TemperatureProfile_Segment (+ a nested struct). private static async Task> DiscoverLeavesAsync() { var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], EnableControllerBrowse = true, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-browse", enumeratorFactory: new OneTagEnumeratorFactory( new AbCipDiscoveredTag("Seg", null, AbCipDataType.Structure, ReadOnly: false))); await drv.InitializeAsync("{}", CancellationToken.None); drv.SeedDiscoveredUdtShapeForTest(Device, "Seg", new AbCipUdtShape("UDT_Segment", 24, [ new AbCipUdtMember("ID", 0, AbCipDataType.DInt, ArrayLength: 1), new AbCipUdtMember("ZZZZZZZZZZUDT_Segm1", 4, AbCipDataType.SInt, ArrayLength: 1), // hidden BIT host new AbCipUdtMember("Enable", 4, AbCipDataType.Bool, ArrayLength: 1), new AbCipUdtMember("RampTime", 8, AbCipDataType.Real, ArrayLength: 1), new AbCipUdtMember("Sub", 12, AbCipDataType.Structure, ArrayLength: 1), ])); drv.SeedDiscoveredUdtShapeForTest(Device, "Sub", new AbCipUdtShape("UDT_Sub", 4, [new AbCipUdtMember("Code", 0, AbCipDataType.Int, ArrayLength: 1)])); var builder = new LeafRecorder(); await drv.DiscoverAsync(builder, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); return builder.Leaves; } /// A raw tag as RawBrowseCommitMapper commits a discovered leaf (plus an optional writable key). private static RawTagEntry Committed(string tagPath, string dataType, bool? writable = null) => new(tagPath, writable is { } w ? $$"""{"tagPath":"{{tagPath}}","dataType":"{{dataType}}","writable":{{(w ? "true" : "false")}}}""" : $$"""{"tagPath":"{{tagPath}}","dataType":"{{dataType}}"}""", WriteIdempotent: false, DeviceName: Device); private static async Task StartAsync(FakeAbCipTagFactory factory, params RawTagEntry[] tags) { var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = tags, Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); return drv; } /// One good read so health holds a concrete Healthy snapshot the refusal must leave untouched. private static async Task PrimeHealthAsync(AbCipDriver drv, string reference) => (await drv.ReadAsync([reference], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); private sealed class RefusingEncodeTag(AbCipTagCreateParams p) : FakeAbCipTag(p) { public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => throw new NotSupportedException("refused locally"); } private sealed class OneTagEnumeratorFactory(params AbCipDiscoveredTag[] tags) : IAbCipTagEnumeratorFactory { public IAbCipTagEnumerator Create() => new Enumerator(tags); private sealed class Enumerator(AbCipDiscoveredTag[] tags) : IAbCipTagEnumerator { public async IAsyncEnumerable EnumerateAsync( AbCipTagCreateParams deviceParams, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; foreach (var t in tags) yield return t; } public void Dispose() { } } } private sealed class LeafRecorder : IAddressSpaceBuilder { public List Leaves { get; } = []; public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Leaves.Add(info); return new Handle(info.FullName); } public void AddProperty(string browseName, DriverDataType dataType, object? value) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } }