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(), }); using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = Guid.NewGuid(), RevisionHash = new string('b', 64), Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); } private static void SeedDeployment( IDbContextFactory dbFactory, string[] equipmentIds, string[] driverIds) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Equipment = equipmentIds.Select(id => new { EquipmentId = id, MachineCode = id.ToUpperInvariant(), UnsLineId = "line-1", Name = id, }).ToArray(), DriverInstances = driverIds.Select(id => new { DriverInstanceId = id, DriverType = "Modbus", Enabled = true, DriverConfig = "{}", }).ToArray(), ScriptedAlarms = Array.Empty(), }); using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = Guid.NewGuid(), RevisionHash = new string('a', 64), Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); } private sealed class RecordingSink : IOpcUaAddressSpaceSink { /// Gets the list of recorded sink calls. public ConcurrentQueue Calls { get; } = new(); /// Gets or sets the count of rebuild address space calls. public int RebuildCalls; /// Records a value write call. /// The OPC UA node ID. /// The value to write. /// The OPC UA quality code. /// The timestamp of the write. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) => Calls.Enqueue($"WV:{nodeId}"); /// Records an alarm state write call. /// The alarm node ID. /// Whether the alarm is active. /// Whether the alarm is acknowledged. /// The timestamp of the state change. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) => Calls.Enqueue($"WA:{alarmNodeId}"); /// Records a folder ensure call. /// The folder node ID. /// The parent node ID, or null if this is a root folder. /// The display name of the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => Calls.Enqueue($"EF:{folderNodeId}"); /// Records a variable ensure call. /// The variable node ID. /// The parent folder node ID, or null if this is a root variable. /// The display name of the variable. /// The OPC UA built-in type name. public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) => Calls.Enqueue($"EV:{variableNodeId}"); /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); } }