using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
/// Regression coverage for 05/STAB-5 — the four per-device fixed-tree caches
/// (LastFixedSnapshots, LastTimers, LastServoLoads, LastSpindleLoads)
/// are written by the background fixed-tree loop while ReadAsync callers read them
/// concurrently. A plain
/// throws / corrupts when a resize races a read — undefined behaviour. They must be
/// .
///
[Trait("Category", "Unit")]
public sealed class FocasFixedTreeConcurrencyTests
{
private static FocasDriver.DeviceState NewDeviceState() =>
new(new FocasHostAddress("10.0.0.5", FocasHostAddress.DefaultPort),
new FocasDeviceOptions("focas://10.0.0.5"));
///
/// Hammers concurrent writers (which force dictionary resizes) against concurrent readers on
/// all four caches. A plain Dictionary is not concurrency-safe: its structural-mutation
/// version check throws "operations that change non-concurrent collections must have exclusive
/// access to the collection", and its lookup collision guard throws "concurrent operations
/// are not supported" — deterministically under this load. A
/// ConcurrentDictionary never throws.
///
///
/// Uses several writers, not one, so the fault is deterministic rather than timing-dependent
/// UB: the fixed-tree caches are shared mutable state with zero synchronization, so ANY
/// concurrent access is the defect — a driver that survives this survives the real
/// writer-loop-vs-reader-callers race the finding cites.
///
[Fact]
public async Task Concurrent_access_to_fixed_tree_caches_never_throws()
{
var state = NewDeviceState();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var faults = new System.Collections.Concurrent.ConcurrentQueue();
const int rounds = 40;
const int growth = 4_000;
void Writer(int seed)
{
try
{
for (var round = 0; round < rounds && !cts.IsCancellationRequested; round++)
{
state.LastFixedSnapshots.Clear();
state.LastServoLoads.Clear();
state.LastSpindleLoads.Clear();
for (var i = 0; i < growth; i++)
{
// Distinct keys force repeated dictionary growth (resize = the race window);
// the Clear() each round keeps the caches churning through resizes.
state.LastFixedSnapshots[$"Axes/A{seed}_{i}/AbsolutePosition"] = i;
state.LastServoLoads[$"Servo{seed}_{i}"] = i;
state.LastSpindleLoads[seed * growth + i] = i;
state.LastTimers[(FocasTimerKind)(i % 4)] = new FocasTimer((FocasTimerKind)(i % 4), i, i);
}
}
}
catch (Exception ex) { faults.Enqueue(ex); }
}
void Reader()
{
try
{
while (!cts.IsCancellationRequested && faults.IsEmpty)
{
for (var i = 0; i < growth; i++)
{
state.LastFixedSnapshots.TryGetValue($"Axes/A0_{i}/AbsolutePosition", out _);
state.LastServoLoads.TryGetValue($"Servo0_{i}", out _);
state.LastSpindleLoads.TryGetValue(i, out _);
state.LastTimers.TryGetValue((FocasTimerKind)(i % 4), out _);
}
}
}
catch (Exception ex) { faults.Enqueue(ex); }
}
var tasks = new List
{
Task.Run(() => Writer(0)),
Task.Run(() => Writer(1)),
Task.Run(() => Writer(2)),
Task.Run(Reader),
Task.Run(Reader),
};
await Task.WhenAll(tasks);
faults.ShouldBeEmpty(
faults.TryPeek(out var first) ? $"concurrent cache access threw: {first}" : "");
}
}