205 lines
6.8 KiB
C#
205 lines
6.8 KiB
C#
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
|
|
|
/// <summary>
|
|
/// WP2.1b — the primitives the batch seam is built from, tested in isolation because the
|
|
/// OPC Foundation Session/Subscription types they drive cannot be faked without a live
|
|
/// server: monitored-item shard placement, the apply-serialization gate, the bounded
|
|
/// pipeline behind the MxGateway supervisory advise, the alarm-stream union prefix, and
|
|
/// the tag-resolution backoff step.
|
|
/// </summary>
|
|
public class BatchSeamPrimitiveTests
|
|
{
|
|
// ── Shard placement ──
|
|
|
|
[Fact]
|
|
public void Sharding_SplitsItemsAtTheBudget()
|
|
{
|
|
// 12,001 items at the 5,000 default → 3 shards (memo §2 sizing example).
|
|
Assert.Equal(3, MonitoredItemShardPlanner.ShardCountFor(12_001, 5_000));
|
|
// 37,500 tags → 8 shards.
|
|
Assert.Equal(8, MonitoredItemShardPlanner.ShardCountFor(37_500, 5_000));
|
|
|
|
var placement = MonitoredItemShardPlanner.Plan([], 5_000, 12_001);
|
|
Assert.Equal(12_001, placement.Count);
|
|
Assert.Equal(0, placement[0]);
|
|
Assert.Equal(0, placement[4_999]);
|
|
Assert.Equal(1, placement[5_000]);
|
|
Assert.Equal(2, placement[10_000]);
|
|
Assert.Equal(2, placement[12_000]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sharding_FillsTheFirstShardWithFreeCapacityBeforeCreatingOne()
|
|
{
|
|
// Shard 0 full, shard 1 has one free slot: the next two items fill shard 1 then
|
|
// open shard 2 — the "first shard with free capacity, else a new shard" policy,
|
|
// which is also what makes an emptied-and-deleted shard's capacity reusable.
|
|
var placement = MonitoredItemShardPlanner.Plan([5_000, 4_999], 5_000, 2);
|
|
Assert.Equal([1, 2], placement);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sharding_NonPositiveBudgetDegradesToOneItemPerShard()
|
|
{
|
|
Assert.Equal([0, 1, 2], MonitoredItemShardPlanner.Plan([], 0, 3));
|
|
}
|
|
|
|
// ── Apply serialization ──
|
|
|
|
[Fact]
|
|
public async Task ApplyGate_NeverRunsTwoSectionsConcurrently()
|
|
{
|
|
var gate = new AsyncSerialGate();
|
|
var inFlight = 0;
|
|
var maxObserved = 0;
|
|
|
|
var tasks = Enumerable.Range(0, 32).Select(_ => gate.RunAsync(async () =>
|
|
{
|
|
var now = Interlocked.Increment(ref inFlight);
|
|
InterlockedMax(ref maxObserved, now);
|
|
await Task.Delay(5);
|
|
Interlocked.Decrement(ref inFlight);
|
|
})).ToArray();
|
|
|
|
await Task.WhenAll(tasks);
|
|
|
|
Assert.Equal(1, maxObserved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyGate_ReleasesWhenASectionThrows()
|
|
{
|
|
var gate = new AsyncSerialGate();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
gate.RunAsync(() => throw new InvalidOperationException("apply failed")));
|
|
|
|
// The gate must still admit the next caller — a faulted ApplyChanges must not
|
|
// wedge every later subscribe/unsubscribe on this client.
|
|
var ran = false;
|
|
await gate.RunAsync(() =>
|
|
{
|
|
ran = true;
|
|
return Task.CompletedTask;
|
|
});
|
|
Assert.True(ran);
|
|
}
|
|
|
|
// ── Bounded pipeline (MxGateway supervisory advise) ──
|
|
|
|
[Fact]
|
|
public async Task BulkPipeline_KeepsInFlightCountWithinTheWindow()
|
|
{
|
|
const int parallelism = 16;
|
|
var inFlight = 0;
|
|
var maxObserved = 0;
|
|
var items = Enumerable.Range(0, 200).ToList();
|
|
|
|
var results = await BulkPipeline.RunAsync(
|
|
items,
|
|
parallelism,
|
|
async (item, _) =>
|
|
{
|
|
var now = Interlocked.Increment(ref inFlight);
|
|
InterlockedMax(ref maxObserved, now);
|
|
await Task.Delay(2);
|
|
Interlocked.Decrement(ref inFlight);
|
|
return item * 2;
|
|
},
|
|
(item, _) => -item);
|
|
|
|
Assert.Equal(items.Count, results.Length);
|
|
Assert.Equal(items.Select(i => i * 2), results);
|
|
Assert.InRange(maxObserved, 1, parallelism);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BulkPipeline_CapturesPerItemFaultsWithoutAbortingTheBatch()
|
|
{
|
|
var results = await BulkPipeline.RunAsync(
|
|
[1, 2, 3],
|
|
4,
|
|
(item, _) => item == 2
|
|
? throw new InvalidOperationException("advise failed")
|
|
: Task.FromResult(item),
|
|
(item, _) => -item);
|
|
|
|
Assert.Equal([1, -2, 3], results);
|
|
}
|
|
|
|
// ── Alarm-stream union prefix ──
|
|
|
|
[Theory]
|
|
[InlineData(new[] { "Area1.Tank1", "Area1.Tank2" }, "Area1.Tank")]
|
|
[InlineData(new[] { "Area1.Tank1" }, "Area1.Tank1")]
|
|
[InlineData(new[] { "Area1.Tank1", "Area2.Tank1" }, "Area")]
|
|
[InlineData(new[] { "Plant.A", "Zone.B" }, "")]
|
|
[InlineData(new[] { "Area1.Tank1", "" }, "")]
|
|
[InlineData(new string[0], "")]
|
|
public void AlarmPrefix_IsTheLongestCommonPrefix(string[] sources, string expected)
|
|
{
|
|
Assert.Equal(expected, AlarmFilterPrefix.LongestCommonPrefix(sources));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmPrefix_CoversOnlySourcesUnderTheLivePrefix()
|
|
{
|
|
Assert.True(AlarmFilterPrefix.Covers("Area1.", "Area1.Tank1"));
|
|
Assert.False(AlarmFilterPrefix.Covers("Area1.", "Area2.Tank1"));
|
|
// A gateway-wide stream covers everything.
|
|
Assert.True(AlarmFilterPrefix.Covers("", "Anything.At.All"));
|
|
}
|
|
|
|
// ── Tag-resolution backoff ──
|
|
|
|
[Fact]
|
|
public void Backoff_DoublesPerFailedRoundAndCapsAtTheMaximum()
|
|
{
|
|
var floor = TimeSpan.FromSeconds(10);
|
|
var max = TimeSpan.FromMinutes(5);
|
|
|
|
var sequence = new List<TimeSpan>();
|
|
var current = floor;
|
|
for (var round = 0; round < 8; round++)
|
|
{
|
|
current = DataConnectionActor.NextTagResolutionInterval(current, floor, max);
|
|
sequence.Add(current);
|
|
}
|
|
|
|
Assert.Equal(
|
|
[
|
|
TimeSpan.FromSeconds(20),
|
|
TimeSpan.FromSeconds(40),
|
|
TimeSpan.FromSeconds(80),
|
|
TimeSpan.FromSeconds(160),
|
|
TimeSpan.FromSeconds(300),
|
|
TimeSpan.FromSeconds(300),
|
|
TimeSpan.FromSeconds(300),
|
|
TimeSpan.FromSeconds(300),
|
|
], sequence);
|
|
}
|
|
|
|
[Fact]
|
|
public void Backoff_MisconfiguredCeilingBelowFloorDegradesToAFixedInterval()
|
|
{
|
|
var floor = TimeSpan.FromSeconds(10);
|
|
var next = DataConnectionActor.NextTagResolutionInterval(floor, floor, TimeSpan.FromSeconds(1));
|
|
Assert.Equal(floor, next);
|
|
}
|
|
|
|
private static void InterlockedMax(ref int target, int value)
|
|
{
|
|
int seen;
|
|
do
|
|
{
|
|
seen = Volatile.Read(ref target);
|
|
if (value <= seen)
|
|
return;
|
|
}
|
|
while (Interlocked.CompareExchange(ref target, value, seen) != seen);
|
|
}
|
|
}
|