using System.Reflection; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// PR-S7-C3 — unit coverage for partitioning by /// resolved publishing interval. Each test wires a small tag map + scan-group rate map /// and asserts the driver spins up exactly the expected number of internal poll loops /// by inspecting the test-only GetPartitionCount / GetPartitionSummary /// entry points. /// /// /// These tests don't initialise the driver (no live PLC) and don't drive ticks — they /// just verify the partitioning math at SubscribeAsync time. End-to-end tick /// cadence is covered by the integration smoke test /// Driver_three_scan_groups_publish_independently. /// [Trait("Category", "Unit")] public sealed class S7ScanGroupPartitioningTests { /// /// resolves a tag's interval against its private /// _tagsByName dictionary, populated in InitializeAsync. The unit /// tests don't run init (no live PLC), so seed the dictionary directly via /// reflection. Mirrors the pattern in S7DiscoveryAndSubscribeTests which /// also exercises pre-init code paths. /// private static void SeedTagMap(S7Driver drv, params S7TagDefinition[] tags) { var field = typeof(S7Driver).GetField("_tagsByName", BindingFlags.NonPublic | BindingFlags.Instance); field.ShouldNotBeNull(); var map = (Dictionary)field!.GetValue(drv)!; foreach (var t in tags) map[t.Name] = t; } [Fact] public async Task Three_distinct_intervals_produce_three_partitions() { var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, ScanGroupIntervals = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Fast"] = TimeSpan.FromMilliseconds(100), ["Medium"] = TimeSpan.FromSeconds(1), ["Slow"] = TimeSpan.FromSeconds(10), }, }; using var drv = new S7Driver(opts, "s7-3rates"); SeedTagMap(drv, new S7TagDefinition("FastTag", "DB1.DBW0", S7DataType.Int16, ScanGroup: "Fast"), new S7TagDefinition("MediumTag", "DB1.DBW2", S7DataType.Int16, ScanGroup: "Medium"), new S7TagDefinition("SlowTag", "DB1.DBW4", S7DataType.Int16, ScanGroup: "Slow")); var handle = await drv.SubscribeAsync( ["FastTag", "MediumTag", "SlowTag"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); drv.GetPartitionCount(handle).ShouldBe(3, "3 distinct intervals → 3 separate poll loops"); var summary = drv.GetPartitionSummary(handle); // Every partition owns exactly one tag — perfect 1:1 mapping. summary.Count.ShouldBe(3); summary.ShouldAllBe(p => p.TagCount == 1); summary.Select(p => p.Interval).Order().ShouldBe(new[] { TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), }); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Same_interval_for_every_tag_collapses_to_one_partition() { var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, ScanGroupIntervals = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Default"] = TimeSpan.FromMilliseconds(500), }, }; using var drv = new S7Driver(opts, "s7-1rate"); SeedTagMap(drv, new S7TagDefinition("A", "DB1.DBW0", S7DataType.Int16, ScanGroup: "Default"), new S7TagDefinition("B", "DB1.DBW2", S7DataType.Int16, ScanGroup: "Default"), new S7TagDefinition("C", "DB1.DBW4", S7DataType.Int16, ScanGroup: "Default")); var handle = await drv.SubscribeAsync( ["A", "B", "C"], TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken); drv.GetPartitionCount(handle).ShouldBe(1, "all three tags share the same group → single poll loop"); drv.GetPartitionSummary(handle)[0].TagCount.ShouldBe(3); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Mixed_two_at_100ms_and_three_at_1s_produces_two_partitions_with_correct_counts() { var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, ScanGroupIntervals = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Hmi"] = TimeSpan.FromMilliseconds(100), ["Slow"] = TimeSpan.FromSeconds(1), }, }; using var drv = new S7Driver(opts, "s7-mixed"); SeedTagMap(drv, new S7TagDefinition("Hmi1", "DB1.DBW0", S7DataType.Int16, ScanGroup: "Hmi"), new S7TagDefinition("Hmi2", "DB1.DBW2", S7DataType.Int16, ScanGroup: "Hmi"), new S7TagDefinition("Slow1", "DB1.DBW10", S7DataType.Int16, ScanGroup: "Slow"), new S7TagDefinition("Slow2", "DB1.DBW12", S7DataType.Int16, ScanGroup: "Slow"), new S7TagDefinition("Slow3", "DB1.DBW14", S7DataType.Int16, ScanGroup: "Slow")); var handle = await drv.SubscribeAsync( ["Hmi1", "Hmi2", "Slow1", "Slow2", "Slow3"], TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken); drv.GetPartitionCount(handle).ShouldBe(2); var summary = drv.GetPartitionSummary(handle); var fast = summary.Single(p => p.Interval == TimeSpan.FromMilliseconds(100)); var slow = summary.Single(p => p.Interval == TimeSpan.FromSeconds(1)); fast.TagCount.ShouldBe(2, "Hmi1 + Hmi2 share the 100 ms partition"); slow.TagCount.ShouldBe(3, "Slow1 + Slow2 + Slow3 share the 1 s partition"); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Tags_without_scan_group_fall_back_to_subscription_default_interval() { var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, // No ScanGroupIntervals map — every tag must resolve to the subscription default. }; using var drv = new S7Driver(opts, "s7-no-groups"); SeedTagMap(drv, new S7TagDefinition("Plain1", "DB1.DBW0", S7DataType.Int16), new S7TagDefinition("Plain2", "DB1.DBW2", S7DataType.Int16)); var handle = await drv.SubscribeAsync( ["Plain1", "Plain2"], TimeSpan.FromMilliseconds(750), TestContext.Current.CancellationToken); drv.GetPartitionCount(handle).ShouldBe(1, "no groups configured → legacy single-partition behaviour"); drv.GetPartitionSummary(handle)[0].Interval.ShouldBe(TimeSpan.FromMilliseconds(750)); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Tag_with_unknown_scan_group_falls_back_to_subscription_default() { // Operator typo: tag declares "Fst" but the rate map has "Fast". The driver should // NOT throw — instead the tag silently falls through to the subscription default, // matching the "config typo degrades, doesn't break" stance from the factory layer. var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, ScanGroupIntervals = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Fast"] = TimeSpan.FromMilliseconds(100), }, }; using var drv = new S7Driver(opts, "s7-typo"); SeedTagMap(drv, new S7TagDefinition("Typo", "DB1.DBW0", S7DataType.Int16, ScanGroup: "Fst"), new S7TagDefinition("Real", "DB1.DBW2", S7DataType.Int16, ScanGroup: "Fast")); var handle = await drv.SubscribeAsync( ["Typo", "Real"], TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); var summary = drv.GetPartitionSummary(handle); summary.Count.ShouldBe(2); summary.Single(p => p.Interval == TimeSpan.FromMilliseconds(100)).TagCount.ShouldBe(1); summary.Single(p => p.Interval == TimeSpan.FromSeconds(2)).TagCount.ShouldBe(1); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Scan_group_lookup_is_case_insensitive() { // The factory DTO already lower-cases keys — runtime lookup must follow suit so a // tag declaring "FAST" still resolves against a rate-map key of "Fast". var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, ScanGroupIntervals = new Dictionary(StringComparer.Ordinal) { ["Fast"] = TimeSpan.FromMilliseconds(100), }, }; using var drv = new S7Driver(opts, "s7-caseins"); SeedTagMap(drv, new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16, ScanGroup: "FAST")); var handle = await drv.SubscribeAsync( ["T"], TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); drv.GetPartitionSummary(handle)[0].Interval.ShouldBe(TimeSpan.FromMilliseconds(100)); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Group_interval_below_100ms_is_floored_to_100ms() { // The 100 ms floor protects the S7 mailbox from sub-scan polling. It applies to // BOTH the subscription default AND any per-group override — a mis-typed 50 ms // group rate must NOT slip below the floor. var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, ScanGroupIntervals = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["TooFast"] = TimeSpan.FromMilliseconds(25), }, }; using var drv = new S7Driver(opts, "s7-floor-group"); SeedTagMap(drv, new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16, ScanGroup: "TooFast")); var handle = await drv.SubscribeAsync( ["T"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); drv.GetPartitionSummary(handle)[0].Interval.ShouldBe(TimeSpan.FromMilliseconds(100)); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Unsubscribe_disposes_every_partition_engine() { // Every partition CTS must be cancelled + disposed so a multi-rate subscription // doesn't leak background poll tasks on unsubscribe. Verified indirectly by the // partition-count dropping to zero post-unsubscribe (the dictionary is purged) and // by Dispose being safe to call again with no exception. var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, ScanGroupIntervals = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["A"] = TimeSpan.FromMilliseconds(100), ["B"] = TimeSpan.FromMilliseconds(200), ["C"] = TimeSpan.FromMilliseconds(500), }, }; using var drv = new S7Driver(opts, "s7-cleanup"); SeedTagMap(drv, new S7TagDefinition("Ta", "DB1.DBW0", S7DataType.Int16, ScanGroup: "A"), new S7TagDefinition("Tb", "DB1.DBW2", S7DataType.Int16, ScanGroup: "B"), new S7TagDefinition("Tc", "DB1.DBW4", S7DataType.Int16, ScanGroup: "C")); var handle = await drv.SubscribeAsync( ["Ta", "Tb", "Tc"], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); drv.GetPartitionCount(handle).ShouldBe(3); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); drv.GetPartitionCount(handle).ShouldBe(0, "post-unsubscribe the subscription is purged from the dictionary"); // Idempotent: a second unsubscribe must be a no-op, not throw. await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [Fact] public async Task Legacy_single_rate_path_unchanged_when_no_tag_carries_a_scan_group() { // Sanity: existing deployments that don't set ScanGroup or ScanGroupIntervals must // see exactly one partition with exactly the requested publishing interval — the // PR is opt-in and zero-impact for legacy callers. var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false }, }; using var drv = new S7Driver(opts, "s7-legacy"); SeedTagMap(drv, new S7TagDefinition("L1", "DB1.DBW0", S7DataType.Int16), new S7TagDefinition("L2", "DB1.DBW2", S7DataType.Int16)); var handle = await drv.SubscribeAsync( ["L1", "L2"], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken); drv.GetPartitionCount(handle).ShouldBe(1); drv.GetPartitionSummary(handle)[0].Interval.ShouldBe(TimeSpan.FromMilliseconds(250)); drv.GetPartitionSummary(handle)[0].TagCount.ShouldBe(2); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } }