using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// PR abcip-3.3 — coverage for the per-device selector. Three /// resolution layers under test: (a) at /// device init (MultiPacket-against-Micro800 fall-back, plain pass-through otherwise), /// (b) sparsity heuristic /// (Auto-mode dispatch), (c) end-to-end dispatch /// verified by the per-device WholeUdt / MultiPacket counters. /// [Trait("Category", "Unit")] public sealed class AbCipReadStrategyTests { private const string Device = "ab://10.0.0.5/1,0"; private const string Micro = "ab://10.0.0.6/"; // ---- Device init resolution ---- [Fact] public async Task Default_ReadStrategy_resolves_to_Auto_on_DeviceState() { var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.Auto); } [Fact] public async Task User_forced_WholeUdt_passes_through_init() { var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix, ReadStrategy: ReadStrategy.WholeUdt)], Probe = new AbCipProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt); } [Fact] public async Task User_forced_MultiPacket_on_ControlLogix_passes_through_init() { var warnings = new List(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix, ReadStrategy: ReadStrategy.MultiPacket)], Probe = new AbCipProbeOptions { Enabled = false }, OnWarning = warnings.Add, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket); warnings.ShouldBeEmpty(); } [Fact] public async Task User_forced_MultiPacket_on_Micro800_falls_back_to_WholeUdt_with_warning() { var warnings = new List(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800, ReadStrategy: ReadStrategy.MultiPacket)], Probe = new AbCipProbeOptions { Enabled = false }, OnWarning = warnings.Add, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt); warnings.ShouldHaveSingleItem(); warnings[0].ShouldContain("Micro800"); warnings[0].ShouldContain("Multi-Service Packet"); } [Fact] public async Task Auto_on_Micro800_stays_Auto_at_init_planner_caps_to_WholeUdt_per_batch() { // Auto resolution does not warn on non-packing families — the per-batch planner caps // the strategy to WholeUdt at dispatch time. Keeping Auto here means a future PR can // change the family-cap policy in one place without touching device init. var warnings = new List(); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800, ReadStrategy: ReadStrategy.Auto)], Probe = new AbCipProbeOptions { Enabled = false }, OnWarning = warnings.Add, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.Auto); warnings.ShouldBeEmpty(); } // ---- Heuristic ---- [Fact] public void Heuristic_picks_MultiPacket_when_subscribed_fraction_below_threshold() { // 5 of 50 subscribed = 0.10, threshold = 0.25 → MultiPacket AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(5, 50, 0.25).ShouldBe(ReadStrategy.MultiPacket); } [Fact] public void Heuristic_picks_WholeUdt_when_subscribed_fraction_above_threshold() { // 40 of 50 subscribed = 0.80, threshold = 0.25 → WholeUdt AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(40, 50, 0.25).ShouldBe(ReadStrategy.WholeUdt); } [Fact] public void Heuristic_at_threshold_boundary_picks_WholeUdt() { // Strictly less than → MultiPacket; equal → WholeUdt. Deterministic boundary behaviour // so tests can pin exact picks without hand-wringing about float comparison drift. AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(10, 40, 0.25).ShouldBe(ReadStrategy.WholeUdt); } [Fact] public void Heuristic_with_zero_total_members_defaults_to_WholeUdt() { AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 0, 0.25).ShouldBe(ReadStrategy.WholeUdt); } [Fact] public void Heuristic_clamps_threshold_below_zero_to_zero() { // Negative threshold collapses to "never MultiPacket" — even a 0-of-N read picks WholeUdt. AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 10, -0.5).ShouldBe(ReadStrategy.WholeUdt); } [Fact] public void Heuristic_clamps_threshold_above_one_to_one() { // Threshold > 1 saturates so any subscribed fraction triggers MultiPacket. AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(9, 10, 5.0).ShouldBe(ReadStrategy.MultiPacket); } // ---- Driver-level dispatch / counters ---- private static AbCipTagDefinition BuildLargeUdt(string name, int memberCount) { var members = new AbCipStructureMember[memberCount]; for (var i = 0; i < memberCount; i++) members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt); return new AbCipTagDefinition(name, Device, name, AbCipDataType.Structure, Members: members); } private static AbCipDriverOptions BuildOptions(ReadStrategy strategy, double threshold = 0.25, AbCipPlcFamily family = AbCipPlcFamily.ControlLogix, params AbCipTagDefinition[] tags) { var host = family == AbCipPlcFamily.Micro800 ? Micro : Device; // Re-bind tag DeviceHostAddress when family flips so single-test reuse keeps // working — the supplied tags are built against Device by default. var rebuiltTags = tags.Select(t => new AbCipTagDefinition( t.Name, host, t.TagPath, t.DataType, t.Writable, t.WriteIdempotent, t.Members, t.SafetyTag, t.StringLength, t.Description)).ToArray(); return new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(host, family, ReadStrategy: strategy, MultiPacketSparsityThreshold: threshold)], Probe = new AbCipProbeOptions { Enabled = false }, Tags = rebuiltTags, }; } [Fact] public async Task Auto_with_sparse_subscription_dispatches_through_MultiPacket() { // 5 subscribed of 50 = 0.10 < 0.25 → MultiPacket var udt = BuildLargeUdt("Tank", 50); var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt); var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(options, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray(); await drv.ReadAsync(refs, CancellationToken.None); var state = drv.GetDeviceState(Device)!; state.MultiPacketGroupsExecuted.ShouldBe(1); state.WholeUdtGroupsExecuted.ShouldBe(0); } [Fact] public async Task Auto_with_dense_subscription_dispatches_through_WholeUdt() { // 40 subscribed of 50 = 0.80 > 0.25 → WholeUdt var udt = BuildLargeUdt("Tank", 50); var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt); var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(options, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray(); await drv.ReadAsync(refs, CancellationToken.None); var state = drv.GetDeviceState(Device)!; state.WholeUdtGroupsExecuted.ShouldBe(1); state.MultiPacketGroupsExecuted.ShouldBe(0); } [Fact] public async Task User_forced_MultiPacket_dispatches_through_MultiPacket_regardless_of_density() { // 40-of-50 dense reads still hit MultiPacket when the user forces it. var udt = BuildLargeUdt("Tank", 50); var options = BuildOptions(ReadStrategy.MultiPacket, threshold: 0.25, tags: udt); var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(options, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray(); await drv.ReadAsync(refs, CancellationToken.None); var state = drv.GetDeviceState(Device)!; state.MultiPacketGroupsExecuted.ShouldBe(1); state.WholeUdtGroupsExecuted.ShouldBe(0); } [Fact] public async Task User_forced_WholeUdt_dispatches_through_WholeUdt_regardless_of_sparsity() { // 1 sparse read of 50 still hits WholeUdt when the user forces it. Note: the WholeUdt // planner demotes 1-member groups to fallback because a single member doesn't beat the // whole-UDT-buffer cost. Verify ReadCount on the parent's runtime stays zero — the // member runtime did the work. var udt = BuildLargeUdt("Tank", 50); var options = BuildOptions(ReadStrategy.WholeUdt, threshold: 0.25, tags: udt); var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(options, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Tank.M0"], CancellationToken.None); var state = drv.GetDeviceState(Device)!; state.MultiPacketGroupsExecuted.ShouldBe(0); // 1-member groups skip WholeUdt grouping per the existing planner contract — the // counter increments only when the planner emits a group, not for the per-tag fallback. state.WholeUdtGroupsExecuted.ShouldBe(0); } [Fact] public async Task Threshold_tunable_higher_value_picks_MultiPacket_for_denser_reads() { // 12 of 50 = 0.24, threshold = 0.5 → MultiPacket (would have been WholeUdt at 0.25). var udt = BuildLargeUdt("Tank", 50); var options = BuildOptions(ReadStrategy.Auto, threshold: 0.5, tags: udt); var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(options, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var refs = Enumerable.Range(0, 12).Select(i => $"Tank.M{i}").ToArray(); await drv.ReadAsync(refs, CancellationToken.None); drv.GetDeviceState(Device)!.MultiPacketGroupsExecuted.ShouldBe(1); } [Fact] public async Task Auto_on_Micro800_caps_to_WholeUdt_even_when_sparse() { // Family doesn't support request packing → Auto must NEVER pick MultiPacket. var udt = BuildLargeUdt("Tank", 50); var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, family: AbCipPlcFamily.Micro800, tags: udt); var factory = new FakeAbCipTagFactory(); var drv = new AbCipDriver(options, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray(); await drv.ReadAsync(refs, CancellationToken.None); var state = drv.GetDeviceState(Micro)!; state.MultiPacketGroupsExecuted.ShouldBe(0); state.WholeUdtGroupsExecuted.ShouldBe(1); } // ---- Family-profile compatibility ---- [Fact] public void Family_profiles_advertise_request_packing_correctly() { AbCipPlcFamilyProfile.ControlLogix.SupportsRequestPacking.ShouldBeTrue(); AbCipPlcFamilyProfile.CompactLogix.SupportsRequestPacking.ShouldBeTrue(); AbCipPlcFamilyProfile.GuardLogix.SupportsRequestPacking.ShouldBeTrue(); AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse(); } // ---- DTO round-trip ---- [Fact] public async Task DTO_round_trips_ReadStrategy_MultiPacket_through_config_json() { var json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "ControlLogix", "ReadStrategy": "MultiPacket", "MultiPacketSparsityThreshold": 0.5 } ], "Probe": { "Enabled": false } } """; var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json); await drv.InitializeAsync(json, CancellationToken.None); var state = drv.GetDeviceState(Device)!; state.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket); state.Options.MultiPacketSparsityThreshold.ShouldBe(0.5); } [Fact] public async Task DTO_round_trips_ReadStrategy_WholeUdt_through_config_json() { var json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "ControlLogix", "ReadStrategy": "WholeUdt" } ], "Probe": { "Enabled": false } } """; var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json); await drv.InitializeAsync(json, CancellationToken.None); drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt); } [Fact] public async Task DTO_omitted_ReadStrategy_falls_back_to_Auto_with_default_threshold() { var json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "ControlLogix" } ], "Probe": { "Enabled": false } } """; var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json); await drv.InitializeAsync(json, CancellationToken.None); var state = drv.GetDeviceState(Device)!; state.ReadStrategy.ShouldBe(ReadStrategy.Auto); state.Options.MultiPacketSparsityThreshold.ShouldBe(0.25); } // ---- Planner output shape (sanity) ---- [Fact] public void MultiPacketPlanner_groups_subscribed_members_by_parent() { var udt = BuildLargeUdt("Tank", 50); var tagsByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Tank"] = udt, }; for (var i = 0; i < 50; i++) { tagsByName[$"Tank.M{i}"] = new AbCipTagDefinition( $"Tank.M{i}", Device, $"Tank.M{i}", AbCipDataType.DInt); } var refs = new[] { "Tank.M0", "Tank.M3", "Tank.M7" }; var plan = AbCipMultiPacketReadPlanner.Build(refs, tagsByName); plan.Batches.Count.ShouldBe(1); plan.Batches[0].ParentName.ShouldBe("Tank"); plan.Batches[0].Members.Count.ShouldBe(3); plan.Fallbacks.ShouldBeEmpty(); } [Fact] public void MultiPacketPlanner_does_not_demote_singletons_unlike_WholeUdt_planner() { // A 1-of-N read is the canonical sparse case — MultiPacket emits a Batch with one // member where WholeUdt would demote to fallback. This is the load-bearing difference. var udt = BuildLargeUdt("Tank", 50); var tagsByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Tank"] = udt, ["Tank.M0"] = new AbCipTagDefinition("Tank.M0", Device, "Tank.M0", AbCipDataType.DInt), }; var plan = AbCipMultiPacketReadPlanner.Build(["Tank.M0"], tagsByName); plan.Batches.Count.ShouldBe(1); plan.Batches[0].Members.Count.ShouldBe(1); plan.Fallbacks.ShouldBeEmpty(); } }