using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; /// /// PR-S7-C3 — end-to-end coverage of per-tag scan-group partitioning. Subscribes three /// tags at three publishing intervals (100 ms / 1 s / 10 s) against the python-snap7 /// S7-1500 fixture and asserts each gets its own tick stream with counts proportional /// to its rate. Scaffold only; runtime execution gated on the Snap7 fixture being up /// in CI. /// [Collection(Snap7ServerCollection.Name)] [Trait("Category", "Integration")] [Trait("Device", "S7_1500")] public sealed class S7_1500ScanGroupTests(Snap7ServerFixture sim) { [Fact] public async Task Driver_three_scan_groups_publish_independently() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Reuse the smoke profile but override Tags + ScanGroupIntervals so each tag // lands in its own group. The tags themselves are already seeded by the snap7 // fixture (DB1.DBW0, DB1.DBW10, DB1.DBD20 — same offsets the smoke tests use). var baseOpts = S7_1500Profile.BuildOptions(sim.Host, sim.Port); var options = new S7DriverOptions { Host = baseOpts.Host, Port = baseOpts.Port, CpuType = baseOpts.CpuType, Rack = baseOpts.Rack, Slot = baseOpts.Slot, Timeout = baseOpts.Timeout, Probe = baseOpts.Probe, // Three groups, three rates. The 10s "Slow" group exercises the assertion that // a slow batch's Task.Delay doesn't block faster partitions from polling — if // the original (single-rate) implementation had been kept, every tag would // tick at the slowest configured interval. ScanGroupIntervals = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Fast"] = TimeSpan.FromMilliseconds(100), ["Medium"] = TimeSpan.FromSeconds(1), ["Slow"] = TimeSpan.FromSeconds(10), }, Tags = [ new S7TagDefinition("FastProbe", "DB1.DBW0", S7DataType.UInt16, ScanGroup: "Fast"), new S7TagDefinition("MediumI16", "DB1.DBW10", S7DataType.Int16, ScanGroup: "Medium"), new S7TagDefinition("SlowI32", "DB1.DBD20", S7DataType.Int32, ScanGroup: "Slow"), ], }; await using var drv = new S7Driver(options, driverInstanceId: "s7-scangroups"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Per-tag tick counters. The OPC UA initial-data push fires once per tag at // subscribe time regardless of partition (Part 4 contract), so we discount the // first tick before evaluating the rate ratio. var ticks = new System.Collections.Concurrent.ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); drv.OnDataChange += (_, e) => ticks.AddOrUpdate(e.FullReference, 1, (_, n) => n + 1); var handle = await drv.SubscribeAsync( ["FastProbe", "MediumI16", "SlowI32"], TimeSpan.FromSeconds(1), // default; ignored because every tag carries a group TestContext.Current.CancellationToken); // Three groups → three partitions. This is the strongest claim of the PR: the // driver split the input list into one poll loop per distinct interval. drv.GetPartitionCount(handle).ShouldBe(3, "three distinct rates → three independent poll loops"); // Run for ~3 s and capture tick counts. With a 100 ms partition, ~30 ticks expected // (minus the initial-data push, plus jitter). With a 1 s partition, ~3 ticks. With // a 10 s partition, only the initial-data push fires inside the window. await Task.Delay(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); // Discount the initial-data push before checking ratios. After the discount, Fast // must have produced strictly more ticks than Medium, and Medium must have // produced at least one tick (Slow stays at 0 inside the 3-second window). var fastSubsequent = Math.Max(0, (ticks.GetValueOrDefault("FastProbe", 0)) - 1); var mediumSubsequent = Math.Max(0, (ticks.GetValueOrDefault("MediumI16", 0)) - 1); // Loose lower bound on Fast — wall-clock jitter on CI runners makes tighter bounds // flaky. Anything above ~10 ticks in 3 s proves the 100 ms partition is actually // running (i.e. it's not blocked behind the 10 s slow partition). fastSubsequent.ShouldBeGreaterThan(10, $"100 ms partition should fire many times in 3 s; observed Fast={fastSubsequent}, Medium={mediumSubsequent}"); // Strict ordering — the whole point of partitioning is that Fast > Medium even // when Slow is sitting on a 10 s Task.Delay. fastSubsequent.ShouldBeGreaterThan(mediumSubsequent, "Fast partition (100 ms) must out-tick Medium partition (1 s) — partitions are independent"); } }