using System.Collections.Concurrent;
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
{
/// Tests that RebuildAddressSpace with dbFactory loads artifact, composes, and applies.
[Fact]
public void RebuildAddressSpace_with_dbFactory_loads_artifact_composes_and_applies()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger.Instance);
SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink,
dbFactory: db,
applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() =>
{
// Add path: Equipment + Driver + Alarm — but only Equipment/Alarm topology triggers
// RebuildAddressSpace. With 2 new equipment we expect one Rebuild call.
sink.RebuildCalls.ShouldBe(1);
}, duration: TimeSpan.FromSeconds(2));
}
/// Tests that rebuild with no artifact is idempotent no-op.
[Fact]
public void Rebuild_with_no_artifact_is_idempotent_no_op()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger.Instance);
// No deployment seeded — LoadLatestArtifact returns empty blob.
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink,
dbFactory: db,
applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
Thread.Sleep(200);
sink.RebuildCalls.ShouldBe(0);
}
/// Tests that second rebuild with same artifact is empty plan no-op.
[Fact]
public void Second_rebuild_with_same_artifact_is_empty_plan_no_op()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger.Instance);
SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty());
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
Thread.Sleep(200);
// Same composition ⇒ plan IsEmpty ⇒ applier not called again.
sink.RebuildCalls.ShouldBe(1);
}
/// Tests that rebuild without dbFactory falls back to raw sink rebuild.
[Fact]
public void Rebuild_without_dbFactory_falls_back_to_raw_sink_rebuild()
{
// Pre-#109 behavior: no dbFactory wired ⇒ RebuildAddressSpace calls _sink.RebuildAddressSpace
// directly. The dev/Mac path before the full integration is bound.
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
}
///
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
/// shape exercised in DeploymentArtifactTests (MAIN + SITE-A, one Galaxy driver +
/// one SystemPlatform tag each). The scoped rebuild for the SITE-A node must surface the
/// SITE-A tag (t-sa → variable F.S1) and NOT MAIN's (t-main →
/// F.M1); the mirror holds for the MAIN node. Without the production scoping edit,
/// the unscoped parse would materialise BOTH variables on every node.
///
[Fact]
public void Rebuild_materialises_only_the_nodes_cluster()
{
// --- SITE-A node: only the SITE-A tag's variable, never MAIN's. ---
var dbA = NewInMemoryDbFactory();
var sinkA = new RecordingSink();
var applierA = new Phase7Applier(sinkA, NullLogger.Instance);
SeedMultiClusterDeployment(dbA);
var siteActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sinkA,
dbFactory: dbA,
applier: applierA,
localNode: NodeId.Parse("site-a-1:4053")));
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
// t-sa (Name "S1", FolderPath "F") → MxAccessRef "F.S1" → variable node "F.S1".
sinkA.Calls.ShouldContain("EV:F.S1");
// t-main (MAIN cluster) must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:F.M1");
// --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. ---
var dbM = NewInMemoryDbFactory();
var sinkM = new RecordingSink();
var applierM = new Phase7Applier(sinkM, NullLogger.Instance);
SeedMultiClusterDeployment(dbM);
var mainActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sinkM,
dbFactory: dbM,
applier: applierM,
localNode: NodeId.Parse("central-1:4053")));
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
sinkM.Calls.ShouldContain("EV:F.M1");
sinkM.Calls.ShouldNotContain("EV:F.S1");
}
///
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster
/// shape the composer emits: a Clusters + Nodes map, one SystemPlatform
/// namespace + Galaxy driver + Galaxy tag per cluster. Used by
/// .
///
private static void SeedMultiClusterDeployment(IDbContextFactory dbFactory)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
Nodes = new[]
{
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
},
Namespaces = new[]
{
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, // NamespaceKind.SystemPlatform
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 },
},
Tags = new[]
{
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
},
ScriptedAlarms = Array.Empty