@@ -0,0 +1,96 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<string, TimeSpan>(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<string, int>(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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-C3 — unit coverage for <see cref="S7Driver.SubscribeAsync"/> 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 <c>GetPartitionCount</c> / <c>GetPartitionSummary</c>
|
||||
/// entry points.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These tests don't initialise the driver (no live PLC) and don't drive ticks — they
|
||||
/// just verify the partitioning math at <c>SubscribeAsync</c> time. End-to-end tick
|
||||
/// cadence is covered by the integration smoke test
|
||||
/// <c>Driver_three_scan_groups_publish_independently</c>.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7ScanGroupPartitioningTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="S7Driver"/> resolves a tag's interval against its private
|
||||
/// <c>_tagsByName</c> dictionary, populated in <c>InitializeAsync</c>. The unit
|
||||
/// tests don't run init (no live PLC), so seed the dictionary directly via
|
||||
/// reflection. Mirrors the pattern in <c>S7DiscoveryAndSubscribeTests</c> which
|
||||
/// also exercises pre-init code paths.
|
||||
/// </summary>
|
||||
private static void SeedTagMap(S7Driver drv, params S7TagDefinition[] tags)
|
||||
{
|
||||
var field = typeof(S7Driver).GetField("_tagsByName", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
field.ShouldNotBeNull();
|
||||
var map = (Dictionary<string, S7TagDefinition>)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<string, TimeSpan>(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<string, TimeSpan>(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<string, TimeSpan>(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<string, TimeSpan>(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<string, TimeSpan>(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<string, TimeSpan>(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<string, TimeSpan>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user