using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
/// Covers the bounded post-connect re-discovery loop: when an driver
/// reaches Connected, runs repeated discovery passes (FOCAS-style:
/// the FixedTree is suppressed until the driver's cache populates ~0–2s after connect) and ships each
/// pass's captured nodes to its parent as . The
/// loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not
/// spin forever. A driver that does not implement produces no passes at all.
///
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
///
/// A discoverable driver whose first two passes yield nothing (cache still warming) and whose third
/// pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty
/// set repeats. The final carries the 3 nodes
/// and no further passes arrive — proving the loop is bounded.
///
[Fact]
public void Discovery_retries_until_set_stabilises_then_stops()
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
// Drive Connecting → Connected; the Connected entry kicks discovery.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4
// (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops.
var msgs = new List();
for (var i = 0; i < 4; i++)
msgs.Add(parent.ExpectMsg(TimeSpan.FromSeconds(2)));
// The loop must STOP once the non-empty set has stabilised — no fifth pass.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// Early passes were empty (FixedTree cache still populating).
msgs[0].Nodes.Count.ShouldBe(0);
msgs[1].Nodes.Count.ShouldBe(0);
// The set then appears and stabilises at 3 nodes.
msgs[2].Nodes.Count.ShouldBe(3);
var final = msgs[^1];
final.Nodes.Count.ShouldBe(3);
final.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" });
// The driver was asked exactly as many times as messages published — no extra zombie pass.
driver.DiscoverCount.ShouldBe(4);
}
/// A driver that does not implement produces no discovery passes —
/// the Connected entry's discovery kick is a no-op, so the parent receives no
/// .
[Fact]
public void Driver_without_ITagDiscovery_produces_no_discovery()
{
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// No discovery capability ⇒ never any DiscoveredNodesReady to the parent.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
///
/// A that also exposes . Each DiscoverAsync
/// pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set —
/// modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put.
///
private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery
{
private int _passCount;
/// Number of passes the actor has driven.
public int DiscoverCount => Volatile.Read(ref _passCount);
/// Streams a growing-then-stable node set into the builder (0,0,3,3,…).
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
var count = pass >= 3 ? 3 : 0;
var fixedTree = builder.Folder("FixedTree", "FixedTree");
for (var i = 0; i < count; i++)
{
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
FullName: $"m.fixed.v{i}",
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
return Task.CompletedTask;
}
}
}