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; /// /// Shares the opcua-publish-meters collection with /// so the two classes NEVER run in parallel. MeterRecorder is a process-wide /// filtered only on the instrument NAME, and /// otopcua.opcua.apply.failed carries no tag identifying which actor instance emitted it (only /// kind=rebuild|nodes) — so it cannot be scoped to one test. The degraded-apply tests here /// ( and /// ) deliberately increment that counter, /// which intermittently broke the sibling class's Total.ShouldBe(0) happy-path assertions when the /// two classes ran concurrently in different collections. Serialising them is the fix. /// [Collection("opcua-publish-meters")] 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: PresenceBudget); sink.RebuildCalls.ShouldBe(0); } /// /// Gitea #641 — every apply ends with CompleteAddressSpaceApply, AFTER the last materialise / announce /// call: that is where the node manager reports the conditions a rebuild carried over (their notifier /// folders are wired by then) and drops any captured state no materialise claimed. /// [Fact] public void An_apply_completes_after_every_materialise_pass() { 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(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget); sink.CompletedAfterCallCount.Single().ShouldBe(sink.Calls.Count, "nothing may follow the completion"); sink.Calls.ShouldContain("NA:line-1"); } /// Gitea #641 — the raw-sink fallback rebuild (no applier) completes too, so the state it captured /// is discarded rather than left for a later add to resurrect. [Fact] public void The_raw_sink_fallback_rebuild_completes_the_apply() { var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(1); } /// 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: PresenceBudget); 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: PresenceBudget); } /// /// mesh-phase4 — the wipe-prevention test. A driver-only node has a NULL dbFactory (ConfigDb is /// admin-only now) but a WIRED applier. A RebuildAddressSpace carrying the artifact bytes in hand /// (from the fetch/cache) must drive the REAL diff-and-apply (the equipment folder is materialised) /// and must NOT take the raw-sink RebuildAddressSpace() fallback, which wipes the address /// space without re-materialising. Before the guard split, a null dbFactory routed straight to that /// fallback and the applier never ran — a FetchAndCache node deployed green but served EMPTY. /// [Fact] public void Rebuild_with_null_factory_but_wired_applier_diff_applies_from_in_hand_bytes() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var artifact = BuildNamedEquipmentArtifact(("eq-1", "Pump-1")); // Driver-only node: null dbFactory, wired applier. var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: null, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact)); // The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised… AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); // …and the address-space-wiping raw-sink fallback was NOT taken. sink.RebuildCalls.ShouldBe(0); } /// /// mesh-phase4 / #485 negative — a driver-only node (null dbFactory, wired applier) that gets a /// RebuildAddressSpace with NO artifact bytes has no way to obtain a config (there is no ConfigDb to /// read). The artifact source yields Array.Empty<byte>(), which the #485 zero-length /// guard treats as "no answer": the rebuild is abandoned WITHOUT touching the served address space — /// the applier never runs and the raw-sink wipe fallback is never taken. Last-known-good stands. /// [Fact] public void Rebuild_with_null_factory_and_no_artifact_abandons_without_wiping() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: null, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); Thread.Sleep(200); sink.RebuildCalls.ShouldBe(0); // the raw-sink wipe fallback was NOT taken sink.Calls.ShouldBeEmpty(); // the applier's diff-and-apply never ran } /// /// mesh-phase4 regression (dev seam) — a node with NO applier at all still falls back to the raw-sink /// rebuild, and now the fallback is keyed on the applier ALONE: even WITH a dbFactory wired, a null /// applier takes the F10b/dev fallback. Proves the guard split did not change the no-applier seam. /// [Fact] public void Rebuild_with_null_applier_falls_back_to_raw_sink_even_with_dbFactory() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: null)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } /// /// mesh-phase4 regression (Direct mode) — a DB-backed node (dbFactory wired, applier wired) with no /// in-hand bytes but a DeploymentId still loads the artifact from the ConfigDb via LoadArtifact /// and drives the applier. The guard split must leave the Direct path byte-identical. /// [Fact] public void Rebuild_direct_mode_with_dbFactory_and_deploymentId_still_loads_and_applies() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep = 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(dep))); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); } /// /// Wiring proof for per-ClusterId scoping: a multi-cluster artifact must materialise ONLY the local /// node's cluster slice. MAIN and SITE-A each own a raw folder → Modbus driver → device → tag; the /// scoped rebuild on the SITE-A node must surface SITE-A's raw variable /// (SA/Modbus/dev-sa/S1) and NOT MAIN's (Main/Modbus/dev-main/M1), and the mirror /// must hold on the MAIN node. Without DeploymentArtifact.ResolveClusterScope, the unscoped /// parse materialises BOTH on every node. /// Revived from the EquipmentTagsDarkBatch4 skip. It was parked on equipment-tag plans /// lighting up "at Batch 4"; Batch 4 retired them instead, so the subject moved to the raw subtree. /// The behaviour under test — cluster scoping — never went dark, and matters more since the mesh /// split (a site node must not materialise another cluster's tags). /// [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 ⇒ no full rebuild; the materialise passes still run the cluster slice. AwaitAssert(() => sinkA.Calls.ShouldContain("EV:SA/Modbus/dev-sa/S1"), duration: PresenceBudget); sinkA.RebuildCalls.ShouldBe(0); // MAIN's raw tag must NOT leak onto the SITE-A node. sinkA.Calls.ShouldNotContain("EV:Main/Modbus/dev-main/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:Main/Modbus/dev-main/M1"), duration: PresenceBudget); sinkM.RebuildCalls.ShouldBe(0); sinkM.Calls.ShouldNotContain("EV:SA/Modbus/dev-sa/S1"); } /// /// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the v3 multi-cluster shape: /// a Clusters + Nodes map, and per cluster a raw folder → Modbus driver → device → /// tag chain plus its UNS area/line/equipment. 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", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" }, new { EquipmentId = "eq-sa", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" }, }, RawFolders = new[] { new { RawFolderId = "rf-main", ParentRawFolderId = (string?)null, Name = "Main", ClusterId = "MAIN" }, new { RawFolderId = "rf-sa", ParentRawFolderId = (string?)null, Name = "SA", ClusterId = "SITE-A" }, }, DriverInstances = new[] { new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-main", ClusterId = "MAIN" }, new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-sa", ClusterId = "SITE-A" }, }, Devices = new[] { new { DeviceId = "dev-main", DriverInstanceId = "main-modbus", Name = "dev-main", DeviceConfig = "{}" }, new { DeviceId = "dev-sa", DriverInstanceId = "sa-modbus", Name = "dev-sa", DeviceConfig = "{}" }, }, TagGroups = Array.Empty(), Tags = new[] { new { TagId = "t-main", DeviceId = "dev-main", TagGroupId = (string?)null, Name = "M1", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, new { TagId = "t-sa", DeviceId = "dev-sa", TagGroupId = (string?)null, Name = "S1", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, }, UnsTagReferences = Array.Empty(), 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: PresenceBudget); 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: PresenceBudget); 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: PresenceBudget); // No new NodeAdded announcement was raised for the rebuild-kind deploy. sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst); } /// /// Issue #485 — a transient ConfigDb error while loading a deploy's artifact must NOT empty the /// served address space. The loader's own log already claims "rebuild becomes no-op", but an /// unreadable artifact used to become an EMPTY composition, which the planner then diffed against /// the live one as a PureRemove and tore every node down. A blip on the way to the database is not /// evidence that the operator deployed an empty configuration: the last-known-good address space /// must stand until a real artifact says otherwise. /// [Fact] public void Rebuild_keeps_the_last_known_good_address_space_when_the_artifact_load_fails() { var db = new FlakyDbFactory(NewInMemoryDbFactory()); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2")); 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.ShouldContain("EF:eq-2"), duration: PresenceBudget); var callsAfterFirst = sink.Calls.Count; // The next deploy arrives while the ConfigDb is briefly unreachable. db.Fail = true; actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(Guid.NewGuid()))); Thread.Sleep(200); sink.Calls.ShouldNotContain(c => c.StartsWith("RE:")); // no equipment subtree torn down sink.RebuildCalls.ShouldBe(0); sink.Calls.Count.ShouldBe(callsAfterFirst); // the failed load touched nothing at all // …and the actor still remembers WHAT is applied: once the database is back, re-applying the same // composition diffs to an empty plan. A clobbered _lastApplied would re-materialise everything. db.Fail = false; actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); Thread.Sleep(200); sink.Calls.Count.ShouldBe(callsAfterFirst); } /// /// Positive control for : /// the guard suppresses teardown ONLY when the artifact could not be read. A real deploy that /// genuinely drops an equipment still tears its subtree down, so the removal path is not simply dead. /// [Fact] public void Rebuild_still_removes_equipment_a_readable_artifact_genuinely_dropped() { var db = new FlakyDbFactory(NewInMemoryDbFactory()); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2")); 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.ShouldContain("EF:eq-2"), duration: PresenceBudget); // eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: PresenceBudget); } /// An whose CreateDbContext can be made to /// throw the way a real transient ConfigDb outage does, so the artifact load fails exactly where issue /// #485 observed it failing. private sealed class FlakyDbFactory(IDbContextFactory inner) : IDbContextFactory { /// Gets or sets a value indicating whether the next context creation should throw. public bool Fail { get; set; } /// Creates a context, or throws when is set. /// A new context. public OtOpcUaConfigDbContext CreateDbContext() => Fail ? throw new InvalidOperationException( "An error occurred using the connection to database 'OtOpcUa' on server 'sql,1433'.") : inner.CreateDbContext(); } /// Build the artifact bytes for the given (equipmentId, name) rows under a shared line — /// WITHOUT sealing them into a deployment. This is what a driver-only / FetchAndCache node carries /// in RebuildAddressSpace.Artifact (no ConfigDb read), so tests can drive the in-hand-bytes /// path directly. private static byte[] BuildNamedEquipmentArtifact(params (string Id, string Name)[] equipment) => 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(), }); /// 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 = BuildNamedEquipmentArtifact(equipment); 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: PresenceBudget); 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: PresenceBudget); 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; } // --------------------------------------------------------------------------------------------------- // 2026-08-15 surgical-deploys (Task 6) — a NON-rebuild apply materialises the plan's DELTA, not the whole // composition. The passes are idempotent, but idempotent is not free. EnsureVariable / EnsureFolder // early-return on a ConcurrentDictionary hit BEFORE taking the node manager's lock, so an already-standing // node costs a dictionary probe, not a lock acquisition — but MaterialiseAlarmCondition DOES take the lock // every time, and the probes are still O(address space) per apply plus the composition walk producing them. // So re-walking the composition made the cost of ANY apply proportional to the size of the served address // space (a one-tag add on a 50k-node server was ~50k sink calls on the publish thread) instead of to the // size of the change. // --------------------------------------------------------------------------------------------------- /// THE headline test. Deploy three raw tags, then deploy the same three PLUS one more: the /// second apply must touch ONLY the added tag. Everything already standing — the three container folders /// and the three existing variables — must not be re-ensured. [Fact] public void Pure_add_materialises_only_the_added_nodes() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedRawTagDeployment(db, new[] { "A", "B", "C" }); 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.ShouldContain("EV:Plant/Modbus/dev1/C"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; // One new tag on an otherwise identical configuration. var dep2 = SeedRawTagDeployment(db, new[] { "A", "B", "C", "D" }); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/D"), duration: PresenceBudget); Thread.Sleep(200); // let any (unwanted) extra pass finish before counting var second = sink.Calls.Skip(afterFirst).ToList(); sink.RebuildCalls.ShouldBe(0); // Exactly one variable ensured — the added tag. The three that were already standing are untouched. second.Where(c => c.StartsWith("EV:")).ShouldBe(new[] { "EV:Plant/Modbus/dev1/D" }); // …and no container folder was re-ensured either (the raw containers did not change). second.ShouldNotContain(c => c.StartsWith("EF:")); } /// The falsification control for the headline test: when the plan DOES force a full rebuild, the /// address space has just been wiped, so every node in the composition must be re-materialised — the delta /// subsets would leave the server serving almost nothing. [Fact] public void Rebuild_still_materialises_the_full_composition() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); 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.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; // Renaming the equipment is a ChangedEquipment delta ⇒ the classifier's default-closed Rebuild. var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); AwaitAssert( () => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); var second = sink.Calls.Skip(afterFirst).ToList(); // The FULL raw subtree came back: both containers and both tags, not just a delta. second.ShouldContain("EF:Plant"); second.ShouldContain("EF:Plant/Modbus/dev1"); second.ShouldContain("EV:Plant/Modbus/dev1/a"); second.ShouldContain("EV:Plant/Modbus/dev1/b"); // …and so did the UNS reference variable (whose own row never changed). second.ShouldContain("EV:filling/line1/station1-renamed/a"); } /// /// The delta subset MUST carry the CURRENT side of every changed UNS reference, not just the added /// ones. AddressSpaceApplier.Apply repairs a UnsReferenceDelta by REMOVING the previous /// node in place and leaving the recreate to the materialise pass; a subset built from /// AddedUnsReferenceVariables alone would remove a re-pointed reference and never bring it back. /// Here the reference is re-pointed from raw tag a to raw tag b (which also moves the /// effective name, and so the UNS NodeId). /// [Fact] public void Changed_uns_reference_is_recreated_after_surgical_remove() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); 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.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; // Re-point the SAME reference row at raw tag "b". var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "b"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/b"), duration: PresenceBudget); var second = sink.Calls.Skip(afterFirst).ToList(); sink.RebuildCalls.ShouldBe(0); // repaired in place, not by wiping the server second.ShouldContain("RV:filling/line1/station1/a"); // the previous node was removed… second.ShouldContain("EV:filling/line1/station1/b"); // …and the current one recreated // …with its Organizes edge pointing at the NEW backing raw path. sink.ReferenceEdges.ShouldContain(("filling/line1/station1/b", "Plant/Modbus/dev1/b")); } /// /// The same repair for the shape the 2026-08-15 review found (commit 875ec9fe): the reference row is /// untouched, but its BACKING raw tag becomes historized. The coincident UnsReferenceDelta must /// still be applied, or the UNS mirror keeps serving no history while the raw node serves it — the /// exact split the Backing* fields exist to close. /// Re-pointed by review I2, which changed HOW this shape is repaired: the delta's identity /// is unchanged (same NodeId, same equipment, same backing RawPath — only the inherited attributes /// moved), so it is now an IN-PLACE UpdateTagAttributes in the Uns realm rather than a /// remove+recreate. The client MonitoredItems on the UNS mirror survive an operator historizing the raw /// tag behind them. The historian-value assertion is unchanged — that is the actual repair being /// pinned; only the mechanism that delivers it moved. /// [Fact] public void Changed_uns_reference_is_updated_in_place_when_the_backing_tag_flips_historian() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); 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.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a", historianTagnameForA: "WW.A"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); // The mirror now carries the backing tag's historian tagname — the repair itself, unchanged by I2. AwaitAssert( () => sink.UpdateTagDetails.ShouldContain( u => u.NodeId == "filling/line1/station1/a" && u.HistorianTagname == "WW.A" && u.Realm == AddressSpaceRealm.Uns), duration: PresenceBudget); Thread.Sleep(200); // let any (unwanted) remove+recreate land before asserting its absence var second = sink.Calls.Skip(afterFirst).ToList(); sink.RebuildCalls.ShouldBe(0); second.ShouldContain("UT:filling/line1/station1/a"); // repaired in place… second.ShouldNotContain("RV:filling/line1/station1/a"); // …NOT torn down (subscriptions survive) } /// An AttributeOnly apply (here a raw tag gaining a historian override, with no UNS reference to /// repair) is handled entirely by the surgical in-place update: no materialise pass has anything to do, so /// not a single node is re-ensured. [Fact] public void Attribute_only_apply_skips_all_materialise_passes_except_hierarchy() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" }); 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.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }, historizedTag: "A"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => sink.Calls.ShouldContain("UT:Plant/Modbus/dev1/A"), duration: PresenceBudget); Thread.Sleep(200); // let any (unwanted) extra pass finish before counting var second = sink.Calls.Skip(afterFirst).ToList(); sink.RebuildCalls.ShouldBe(0); second.Where(c => c.StartsWith("EV:")).ShouldBeEmpty(); // nothing re-ensured second.Where(c => c.StartsWith("EF:")).ShouldBeEmpty(); second.ShouldContain("UT:Plant/Modbus/dev1/A"); // the in-place attribute update DID happen } /// /// 2026-08-15 surgical-deploys review C1 — THE headline degraded-apply test. Before the delta /// materialisation the full passes re-ensured EVERY node on EVERY apply, so a swallowed /// EnsureVariable fault silently repaired itself on the next deploy. The delta path removed that /// accidental self-healing: once _lastApplied records a composition, the failed node is never in /// a diff set again and EnsureVariable's existing-node early-return means it is never re-created /// — the node is lost until a rebuild happens to fire. So a non-rebuild apply that reports /// failedNodes > 0 must NOT advance _lastApplied: the next deploy re-diffs the same /// composition and re-ensures what failed. /// [Fact] public void Degraded_non_rebuild_apply_does_not_advance_last_applied() { var db = NewInMemoryDbFactory(); // The sink faults on exactly one of the two added variables — a PureAdd apply that lands B but // loses A, with no rebuild anywhere in sight. var sink = new RecordingSink { FailEnsureVariableFor = "Plant/Modbus/dev1/A" }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" }); 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.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget); Thread.Sleep(200); sink.RebuildCalls.ShouldBe(0); // genuinely a non-rebuild (PureAdd) apply var afterFirst = sink.Calls.Count; // The fault clears (a transient sink fault, an SDK hiccup). Re-deploy the SAME composition: because // the degraded apply did not record it as applied, the planner re-diffs A (and B) as ADDED and the // materialise pass re-ensures them. With _lastApplied advanced this second apply would diff to an // EMPTY plan and A would be gone from the served address space forever. sink.FailEnsureVariableFor = null; var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert( () => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/A"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); } /// /// The other half of C1's rule: a REBUILD that also reports failed nodes DOES advance /// _lastApplied. A rebuild already re-materialised the whole composition, and the applier's /// one-way rebuild ratchet is the resync mechanism for anything it could not place — holding the /// composition back here would make every subsequent deploy re-diff the entire address space as added. /// Proven by the third apply of the same composition NOT rebuilding again (it diffs to an empty plan). /// Gitea #601 review: that empty plan is no longer a silent no-op, though — the previous apply was a /// degraded rebuild, so the identical re-deploy re-ensures the whole composition (idempotently, no /// rebuild) and thereby repairs the node the rebuild could not place. /// [Fact] public void Rebuild_with_failures_still_advances_last_applied() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); 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.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); // Renaming the equipment is ChangedEquipment ⇒ the classifier's default-closed Rebuild; the sink // faults on one variable of the full re-materialise that follows. sink.FailEnsureVariableFor = "Plant/Modbus/dev1/b"; var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); Thread.Sleep(200); var afterSecond = sink.Calls.Count; // Re-deploy the SAME composition. _lastApplied advanced despite the failure, so this diffs to an // empty plan — no second rebuild (an un-advanced composition would re-diff ChangedEquipment and // rebuild again) — but the degraded rebuild is remembered, so the failed variable is re-ensured. sink.FailEnsureVariableFor = null; var dep3 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep3))); AwaitAssert( () => sink.Calls.Skip(afterSecond).ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(1); // Repaired ⇒ genuinely clean now, so a FOURTH identical deploy is the ordinary empty-plan no-op. Thread.Sleep(200); var afterRepair = sink.Calls.Count; var dep4 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep4))); Thread.Sleep(300); sink.Calls.Count.ShouldBe(afterRepair); sink.RebuildCalls.ShouldBe(1); } /// Seal a deployment whose raw tree is Plant/Modbus/dev1 carrying one tag per name in /// (RawPath Plant/Modbus/dev1/<name>). There is no UNS topology, so /// the composition is raw-only. names one tag that carries an /// isHistorized TagConfig, which is an in-place-updatable (surgical) attribute change. /// Deliberately NOT a params method, and deliberately not overloaded. The first draft /// was (dbFactory, string? historizedTag = null, params string[] tagNames) alongside a convenience /// (dbFactory, params string[] tagNames); Seed(db, "A", "B") then bound to the FIRST overload /// in expanded form — historizing a tag called "A" and seeding only "B" — which compiled clean and made a /// delta test silently assert against a one-tag composition. private static Guid SeedRawTagDeployment( IDbContextFactory dbFactory, string[] tagNames, string? historizedTag = null) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = Array.Empty(), UnsLines = Array.Empty(), Equipment = Array.Empty(), RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, TagGroups = Array.Empty(), Tags = tagNames.Select(n => new { TagId = $"t-{n}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = n, DataType = "Float", AccessLevel = 0, TagConfig = string.Equals(n, historizedTag, StringComparison.Ordinal) ? "{\"isHistorized\":true}" : "{}", }).ToArray(), UnsTagReferences = Array.Empty(), ScriptedAlarms = Array.Empty(), }); return SealArtifact(dbFactory, artifact); } /// Seal a deployment carrying BOTH a raw tree (Plant/Modbus/dev1 with tags a + /// b) and a UNS topology (filling / line1 / ) whose /// single UnsTagReference row (id ref-1, stable across deploys) points at /// . Re-pointing that row across two deploys yields a /// UnsReferenceDelta; renaming the equipment yields a rebuild-forcing ChangedEquipment. /// historizes raw tag a with that override, which makes the /// reference's Backing* mirror fields differ (the 875ec9fe repair path). private static Guid SeedUnsRawDeployment( IDbContextFactory dbFactory, string equipmentName, string referencedTag, string? historianTagnameForA = null) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line1" } }, Equipment = new[] { new { EquipmentId = "eq-1", UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" } }, RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, TagGroups = Array.Empty(), Tags = new[] { new { TagId = "t-a", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "a", DataType = "Float", AccessLevel = 0, TagConfig = historianTagnameForA is null ? "{}" : $"{{\"isHistorized\":true,\"historianTagname\":\"{historianTagnameForA}\"}}", }, new { TagId = "t-b", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "b", DataType = "Float", AccessLevel = 0, TagConfig = "{}", }, }, UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = $"t-{referencedTag}" }, }, ScriptedAlarms = Array.Empty(), }); return SealArtifact(dbFactory, artifact); } /// Seal as a new deployment and return its id. private static Guid SealArtifact(IDbContextFactory dbFactory, byte[] artifact) { var id = Guid.NewGuid(); using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id, RevisionHash = new string('e', 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 every EnsureVariable call with the attributes the applier resolved for it (the /// ordered queue only carries node ids). public ConcurrentQueue<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> VariableDetails { get; } = new(); /// Gets every AddReference (Organizes) edge the applier wired. public ConcurrentQueue<(string Source, string Target)> ReferenceEdges { get; } = new(); /// Gets or sets the count of rebuild address space calls. public int RebuildCalls; /// Gets or sets the node id whose throws (null ⇒ none). The /// applier's SafeEnsureVariable swallows the throw into its failed-node tally, which is exactly /// the degraded-apply shape the C1 no-advance rule exists for. Settable mid-test so a transient fault /// can clear between deploys. public volatile string? FailEnsureVariableFor; /// Gets every surgical in-place tag-attribute update with the attributes the applier resolved /// for it (the ordered queue only carries node ids). public ConcurrentQueue<(string NodeId, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> UpdateTagDetails { get; } = new(); /// 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, uint statusCode, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => 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 WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => 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, AddressSpaceRealm realm, 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, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => 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, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal)) throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}"); Calls.Enqueue($"EV:{variableNodeId}"); VariableDetails.Enqueue((variableNodeId, parentFolderNodeId, writable, historianTagname, realm)); } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Gets, per call, how many had been /// recorded at that moment — so a test can prove the completion came after every pass (Gitea #641). /// Kept out of so existing call-count assertions are unaffected. public ConcurrentQueue CompletedAfterCallCount { get; } = new(); /// Records the end of an apply pass (Gitea #641). public void CompleteAddressSpaceApply() => CompletedAfterCallCount.Enqueue(Calls.Count); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}"); public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") => ReferenceEdges.Enqueue((sourceNodeId, targetNodeId)); /// 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, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"UT:{variableNodeId}"); UpdateTagDetails.Enqueue((variableNodeId, writable, historianTagname, realm)); 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, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameQueue.Enqueue((folderNodeId, displayName)); return true; } /// Records a surgical variable-node removal (always succeeds in this recording sink). public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RV:{variableNodeId}"); return true; } /// Records a surgical alarm-condition-node removal (always succeeds in this recording sink). public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RA:{alarmNodeId}"); return true; } /// Records a surgical equipment-subtree removal (always succeeds in this recording sink). public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RE:{equipmentNodeId}"); return true; } } }