using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Regression tests for the High code-review findings Driver.AbCip-001 / -003 / -008. /// (Driver.AbCip-002 is covered by .) /// [Trait("Category", "Unit")] public sealed class AbCipDriverCodeReviewRegressionTests { private const string Device = "ab://10.0.0.5/1,0"; // ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ---- [Fact] public async Task InitializeAsync_applies_devices_and_tags_from_the_config_json() { // Constructed with NO devices/tags — the JSON is the only source of config. var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1"); const string json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.9/1,0", "PlcFamily": "ControlLogix" } ], "Tags": [ { "Name": "Speed", "DeviceHostAddress": "ab://10.0.0.9/1,0", "TagPath": "Speed", "DataType": "DInt" } ] } """; await drv.InitializeAsync(json, CancellationToken.None); drv.DeviceCount.ShouldBe(1); drv.GetDeviceState("ab://10.0.0.9/1,0").ShouldNotBeNull(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task ReinitializeAsync_with_a_changed_config_json_picks_up_the_new_device() { var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetDeviceState(Device).ShouldNotBeNull(); // Reinitialize with a JSON that names a DIFFERENT device — the change must take effect // instead of being silently discarded (Driver.AbCip-001). const string changed = """ { "Devices": [ { "HostAddress": "ab://10.0.0.99/1,0" } ] } """; await drv.ReinitializeAsync(changed, CancellationToken.None); drv.DeviceCount.ShouldBe(1); drv.GetDeviceState("ab://10.0.0.99/1,0").ShouldNotBeNull(); drv.GetDeviceState(Device).ShouldBeNull(); } [Fact] public async Task InitializeAsync_with_blank_json_keeps_construction_time_options() { // The test seam: a driver constructed with explicit options + handed "{}" must keep // those options (otherwise every fake-backed unit test would lose its config). var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.DeviceCount.ShouldBe(1); drv.GetDeviceState(Device).ShouldNotBeNull(); } // ---- Driver.AbCip-003 — declaration-only whole-UDT grouping is opt-in ---- [Fact] public async Task Whole_udt_grouping_is_off_by_default_so_members_read_per_tag() { // Default options — EnableDeclarationOnlyUdtGrouping is false. Reading two members of // a UDT must NOT collapse into a single declaration-order whole-UDT read, because the // controller may not lay members out in declaration order. var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [ new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Speed", AbCipDataType.DInt), new AbCipStructureMember("Torque", AbCipDataType.Real), ]), ], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); // Each member got its own per-tag runtime; the parent "Motor" runtime was never created. factory.Tags.ShouldContainKey("Motor.Speed"); factory.Tags.ShouldContainKey("Motor.Torque"); factory.Tags.ShouldNotContainKey("Motor"); } [Fact] public void Planner_forms_no_groups_when_declaration_only_grouping_is_disabled() { var members = new[] { new AbCipStructureMember("Speed", AbCipDataType.DInt), new AbCipStructureMember("Torque", AbCipDataType.Real), }; var tags = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Motor"] = new("Motor", Device, "Motor", AbCipDataType.Structure, Members: members), ["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt), ["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real), }; var plan = AbCipUdtReadPlanner.Build( ["Motor.Speed", "Motor.Torque"], tags, enableDeclarationOnlyGrouping: false); plan.Groups.ShouldBeEmpty(); plan.Fallbacks.Count.ShouldBe(2); } // ---- Driver.AbCip-008 — ShutdownAsync awaits probe loops; reads are concurrency-safe ---- [Fact] public async Task ShutdownAsync_awaits_the_probe_loop_before_returning() { var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = "ProbeTag", Interval = TimeSpan.FromMilliseconds(20), }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Give the probe loop a moment to actually start spinning. await Task.Delay(60); // Must complete cleanly — no ObjectDisposedException from a loop racing a disposed CTS. await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None)); drv.DeviceCount.ShouldBe(0); } [Fact] public async Task ShutdownAsync_is_idempotent() { var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None)); } [Fact] public async Task Concurrent_first_reads_of_the_same_tag_do_not_corrupt_the_runtime_cache() { // Two concurrent ReadAsync calls that both miss the runtime cache must not throw and // must not leave the device with two un-disposed runtimes for one tag (Driver.AbCip-008 // ConcurrentDictionary + TryAdd loser-disposal). var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)], }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var reads = Enumerable.Range(0, 16) .Select(_ => drv.ReadAsync(["Speed"], CancellationToken.None)) .ToArray(); var allResults = await Task.WhenAll(reads); foreach (var result in allResults) result.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); } }