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 { /// R2-07 — RebuildAddressSpace with dbFactory loads the artifact, composes, and applies. Two /// brand-new equipment are a PureAdd: the Materialise passes create the equipment folders WITHOUT a full /// rebuild (existing subscriptions survive), and the added parents are announced via NodeAdded. (Was: /// pre-R2-07 this asserted one full rebuild.) [Fact] public void RebuildAddressSpace_with_dbFactory_loads_artifact_composes_and_applies() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(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(() => { // PureAdd: the equipment folders are materialised, no full rebuild, and the added parent // (the shared line-1) is announced exactly once. sink.Calls.ShouldContain("EF:eq-1"); sink.Calls.ShouldContain("EF:eq-2"); sink.Calls.ShouldContain("NA:line-1"); }, duration: TimeSpan.FromSeconds(2)); sink.RebuildCalls.ShouldBe(0); } /// 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 AddressSpaceApplier(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 a second rebuild with the same artifact is an empty-plan no-op. The first deploy /// (one new equipment) is a PureAdd — no full rebuild, but it DOES materialise the folder; the second /// deploy of the identical composition diffs to an empty plan and the actor short-circuits, adding no /// further sink calls. [Fact] public void Second_rebuild_with_same_artifact_is_empty_plan_no_op() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(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.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild var callsAfterFirst = sink.Calls.Count; actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); Thread.Sleep(200); // Same composition ⇒ plan IsEmpty ⇒ applier + materialise passes not run again. sink.Calls.Count.ShouldBe(callsAfterFirst); sink.RebuildCalls.ShouldBe(0); } /// 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 equipment tag each — Galaxy points are ordinary equipment tags now). The scoped /// rebuild for the SITE-A node must surface the SITE-A tag (t-sa → folder-scoped /// variable eq-sa/F/S1) and NOT MAIN's (t-maineq-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 AddressSpaceApplier(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())); // PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice. // t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1". AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: TimeSpan.FromSeconds(2)); sinkA.RebuildCalls.ShouldBe(0); // t-main (MAIN cluster) must NOT leak onto the SITE-A node. sinkA.Calls.ShouldNotContain("EV:eq-main/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 AddressSpaceApplier(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.Calls.ShouldContain("EV:eq-main/F/M1"), duration: TimeSpan.FromSeconds(2)); sinkM.RebuildCalls.ShouldBe(0); sinkM.Calls.ShouldNotContain("EV:eq-sa/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 Equipment namespace + /// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now). /// 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" }, }, UnsAreas = new[] { new { UnsAreaId = "area-main", ClusterId = "MAIN", Name = "main-area" }, new { UnsAreaId = "area-sa", ClusterId = "SITE-A", Name = "sa-area" }, }, UnsLines = new[] { new { UnsLineId = "line-main", UnsAreaId = "area-main", Name = "main-line" }, new { UnsLineId = "line-sa", UnsAreaId = "area-sa", Name = "sa-line" }, }, Equipment = new[] { new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" }, new { EquipmentId = "eq-sa", DriverInstanceId = "sa-galaxy", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" }, }, 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 = 0 }, // NamespaceKind.Equipment new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 }, }, Tags = new[] { new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", 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(); } /// R2-07 T4b — a PureAdd deploy (here a single new equipment) does NOT rebuild, and the /// NodeAdded announcement is raised AFTER the Materialise passes have created the node (so the node /// exists when a re-browsing client arrives). Proven via the ordered Calls queue: the "NA:" /// announcement index is strictly after the "EF:" materialise index. [Fact] public void Pure_add_announces_node_after_materialising_and_never_rebuilds() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(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.Calls.ShouldContain("NA:line-1"), duration: TimeSpan.FromSeconds(2)); sink.RebuildCalls.ShouldBe(0); var calls = sink.Calls.ToList(); var materialiseIdx = calls.IndexOf("EF:eq-1"); var announceIdx = calls.IndexOf("NA:line-1"); materialiseIdx.ShouldBeGreaterThanOrEqualTo(0); announceIdx.ShouldBeGreaterThan(materialiseIdx); // announced AFTER the node was materialised } /// R2-07 T4b — when a deploy forces a full rebuild (here a renamed equipment ⇒ ChangedEquipment /// ⇒ Rebuild) even though it ALSO adds an equipment, the post-materialise announce hook is SKIPPED (the /// rebuild made subscriptions moot). Proven by capturing the NodeAdded count after the first pure-add /// deploy and asserting the rebuild-forcing second deploy adds none. [Fact] public void Rebuild_kind_deploy_skips_the_node_added_announce() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: TimeSpan.FromSeconds(2)); sink.RebuildCalls.ShouldBe(0); var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:")); // Second deploy: eq-1 RENAMED (ChangedEquipment ⇒ Rebuild) AND a new eq-2 (an add). The rebuild // fires, so the announce hook is skipped despite eq-2 being added. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); // No new NodeAdded announcement was raised for the rebuild-kind deploy. sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst); } /// Seal a deployment carrying the given (equipmentId, name) rows under a shared line, returning /// the new DeploymentId so a test can target THAT artifact. Renaming an equipment across two such /// deployments produces a ChangedEquipment delta (Rebuild), while adding a row is a PureAdd. private static Guid SeedNamedEquipmentDeployment( IDbContextFactory dbFactory, params (string Id, string Name)[] equipment) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, Equipment = equipment.Select(e => new { EquipmentId = e.Id, DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = e.Name, MachineCode = e.Id.ToUpperInvariant(), }).ToArray(), DriverInstances = Array.Empty(), Namespaces = Array.Empty(), Tags = Array.Empty(), ScriptedAlarms = Array.Empty(), }); var id = Guid.NewGuid(); using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id, RevisionHash = new string('d', 64), Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } 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(); } /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area Name differs; /// no equipment/driver/alarm/tag delta) must NOT short-circuit at the actor's IsEmpty gate: the second /// RebuildAddressSpace drives the in-place folder display-name refresh (a surgical /// UpdateFolderDisplayName call) without a full rebuild. Proves the planner→applier→sink chain /// reaches the apply path through the actor for a rename-only redeploy. [Fact] public void Rebuild_with_area_rename_only_updates_folder_in_place_without_rebuild() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedAreaDeployment(db, areaName: "Plant North"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: applier)); // First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd // (area + line + equipment all added), so NO full rebuild; the folder is materialised directly. actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: TimeSpan.FromSeconds(2)); sink.RebuildCalls.ShouldBe(0); // Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and // drive a surgical in-place folder rename (NOT a rebuild, NOT a no-op). var dep2 = SeedAreaDeployment(db, areaName: "Plant South"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => { sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South")); }, duration: TimeSpan.FromSeconds(2)); sink.RebuildCalls.ShouldBe(0); // the rename did NOT force a full rebuild } /// Seal a deployment whose area carries , with a line + one /// equipment under it. The equipment makes the FIRST deploy a structural rebuild (so the area folder is /// materialised); a later redeploy that changes ONLY the area Name is then a pure rename. Returns the /// new DeploymentId so the test can target THAT artifact (apply-time, not-yet-sealed semantics). private static Guid SeedAreaDeployment(IDbContextFactory dbFactory, string areaName) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = areaName } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Cell A" } }, Equipment = new[] { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Pump-1", MachineCode = "EQ-1" } }, DriverInstances = Array.Empty(), Namespaces = Array.Empty(), Tags = Array.Empty(), ScriptedAlarms = Array.Empty(), }); var id = Guid.NewGuid(); using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id, RevisionHash = new string('c', 64), Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink { /// 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; /// Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001). public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new(); /// Gets the list of recorded surgical folder display-name update calls. public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls => FolderRenameQueue.ToList(); /// 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 condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The timestamp of the state change. public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) => Calls.Enqueue($"WA:{alarmNodeId}"); /// Records a materialise-alarm-condition call. /// The alarm node ID (== ScriptedAlarmId). /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) => Calls.Enqueue($"MA:{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. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => Calls.Enqueue($"EV:{variableNodeId}"); /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. public void RaiseNodesAddedModelChange(string affectedNodeId) => Calls.Enqueue($"NA:{affectedNodeId}"); /// Records a surgical in-place tag-attribute update (always succeeds in this recording sink). public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) { Calls.Enqueue($"UT:{variableNodeId}"); return true; } /// Records a surgical in-place folder display-name update (always succeeds in this recording sink). /// The folder node ID to update in place. /// The new display name to apply. public bool UpdateFolderDisplayName(string folderNodeId, string displayName) { FolderRenameQueue.Enqueue((folderNodeId, displayName)); return true; } /// Records a surgical variable-node removal (always succeeds in this recording sink). public bool RemoveVariableNode(string variableNodeId) { Calls.Enqueue($"RV:{variableNodeId}"); return true; } /// Records a surgical alarm-condition-node removal (always succeeds in this recording sink). public bool RemoveAlarmConditionNode(string alarmNodeId) { Calls.Enqueue($"RA:{alarmNodeId}"); return true; } /// Records a surgical equipment-subtree removal (always succeeds in this recording sink). public bool RemoveEquipmentSubtree(string equipmentNodeId) { Calls.Enqueue($"RE:{equipmentNodeId}"); return true; } } }