Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
T
Joseph Doherty 52708bff52 fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list
writes (its element types are only Boolean/Int32/Float/Double/String/
DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a
Double list to a Float array. EvaluateEquipmentWriteStructure now converts
a numeric array of another numeric element type element-wise, CHECKED, into
the node's element type and hands the converted array back through
OnEquipmentTagWrite's ref value, so the node stores and the driver receives
the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to-
integer or Float-overflowing element refuses the whole write BadTypeMismatch
(nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays
closed. Non-numeric mismatches stay refused. The wrong 'no client relies on
a looser rule' comment is corrected.

MINOR-1: the array-value-to-scalar-node refusal broke writes back to
browse-committed array tags (committed scalar, isArray never set) on
pass-through drivers. It now applies only when the owning driver's scalar
encoder cannot take an array (DriverArrayWriteSupport.
RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan.
ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue
-> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node
manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the
classifier whitelist (driver-type edit rebuilds); a mirror whose flag
changes is recreated, not updated in place.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:31:46 -04:00

1097 lines
62 KiB
C#

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;
/// <summary>
/// Shares the <c>opcua-publish-meters</c> collection with <see cref="OpcUaPublishActorApplyFailureTests"/>
/// so the two classes NEVER run in parallel. <c>MeterRecorder</c> is a process-wide
/// <see cref="System.Diagnostics.Metrics.MeterListener"/> filtered only on the instrument NAME, and
/// <c>otopcua.opcua.apply.failed</c> carries no tag identifying which actor instance emitted it (only
/// <c>kind=rebuild|nodes</c>) — so it cannot be scoped to one test. The degraded-apply tests here
/// (<see cref="Degraded_non_rebuild_apply_does_not_advance_last_applied"/> and
/// <see cref="Rebuild_with_failures_still_advances_last_applied"/>) deliberately increment that counter,
/// which intermittently broke the sibling class's <c>Total.ShouldBe(0)</c> happy-path assertions when the
/// two classes ran concurrently in different collections. Serialising them is the fix.
/// </summary>
[Collection("opcua-publish-meters")]
public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
{
/// <summary>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.)</summary>
[Fact]
public void RebuildAddressSpace_with_dbFactory_loads_artifact_composes_and_applies()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>Tests that rebuild with no artifact is idempotent no-op.</summary>
[Fact]
public void Rebuild_with_no_artifact_is_idempotent_no_op()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>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.</summary>
[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<AddressSpaceApplier>.Instance);
SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
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);
}
/// <summary>Tests that rebuild without dbFactory falls back to raw sink rebuild.</summary>
[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);
}
/// <summary>
/// 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 <c>RebuildAddressSpace()</c> 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.
/// </summary>
[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<AddressSpaceApplier>.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);
}
/// <summary>
/// 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 <c>Array.Empty&lt;byte&gt;()</c>, 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.
/// </summary>
[Fact]
public void Rebuild_with_null_factory_and_no_artifact_abandons_without_wiping()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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 <c>LoadArtifact</c>
/// and drives the applier. The guard split must leave the Direct path byte-identical.
/// </summary>
[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<AddressSpaceApplier>.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);
}
/// <summary>
/// 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
/// (<c>SA/Modbus/dev-sa/S1</c>) and NOT MAIN's (<c>Main/Modbus/dev-main/M1</c>), and the mirror
/// must hold on the MAIN node. Without <c>DeploymentArtifact.ResolveClusterScope</c>, the unscoped
/// parse materialises BOTH on every node.
/// <para>Revived from the <c>EquipmentTagsDarkBatch4</c> 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).</para>
/// </summary>
[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<AddressSpaceApplier>.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<AddressSpaceApplier>.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");
}
/// <summary>
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the v3 multi-cluster shape:
/// a <c>Clusters</c> + <c>Nodes</c> map, and per cluster a raw folder → Modbus driver → device →
/// tag chain plus its UNS area/line/equipment. Used by
/// <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
/// </summary>
private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> 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<object>(),
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<object>(),
ScriptedAlarms = Array.Empty<object>(),
});
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();
}
/// <summary>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 <c>Calls</c> queue: the "NA:"
/// announcement index is strictly after the "EF:" materialise index.</summary>
[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<AddressSpaceApplier>.Instance);
SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
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
}
/// <summary>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.</summary>
[Fact]
public void Rebuild_kind_deploy_skips_the_node_added_announce()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
/// <summary>
/// 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.
/// </summary>
[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<AddressSpaceApplier>.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);
}
/// <summary>
/// Positive control for <see cref="Rebuild_keeps_the_last_known_good_address_space_when_the_artifact_load_fails"/>:
/// 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.
/// </summary>
[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<AddressSpaceApplier>.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);
}
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> 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.</summary>
private sealed class FlakyDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Gets or sets a value indicating whether the next context creation should throw.</summary>
public bool Fail { get; set; }
/// <summary>Creates a context, or throws when <see cref="Fail"/> is set.</summary>
/// <returns>A new context.</returns>
public OtOpcUaConfigDbContext CreateDbContext() => Fail
? throw new InvalidOperationException(
"An error occurred using the connection to database 'OtOpcUa' on server 'sql,1433'.")
: inner.CreateDbContext();
}
/// <summary>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 <c>RebuildAddressSpace.Artifact</c> (no ConfigDb read), so tests can drive the in-hand-bytes
/// path directly.</summary>
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<object>(),
Namespaces = Array.Empty<object>(),
Tags = Array.Empty<object>(),
ScriptedAlarms = Array.Empty<object>(),
});
/// <summary>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.</summary>
private static Guid SeedNamedEquipmentDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> 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<OtOpcUaConfigDbContext> 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<object>(),
});
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();
}
/// <summary>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
/// <c>UpdateFolderDisplayName</c> call) without a full rebuild. Proves the planner→applier→sink chain
/// reaches the apply path through the actor for a rename-only redeploy.</summary>
[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<AddressSpaceApplier>.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
}
/// <summary>Seal a deployment whose area carries <paramref name="areaName"/>, 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).</summary>
private static Guid SeedAreaDeployment(IDbContextFactory<OtOpcUaConfigDbContext> 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<object>(),
Namespaces = Array.Empty<object>(),
Tags = Array.Empty<object>(),
ScriptedAlarms = Array.Empty<object>(),
});
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.
// ---------------------------------------------------------------------------------------------------
/// <summary>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.</summary>
[Fact]
public void Pure_add_materialises_only_the_added_nodes()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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:"));
}
/// <summary>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.</summary>
[Fact]
public void Rebuild_still_materialises_the_full_composition()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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");
}
/// <summary>
/// The delta subset MUST carry the CURRENT side of every changed UNS reference, not just the added
/// ones. <c>AddressSpaceApplier.Apply</c> repairs a <c>UnsReferenceDelta</c> by REMOVING the previous
/// node in place and leaving the recreate to the materialise pass; a subset built from
/// <c>AddedUnsReferenceVariables</c> alone would remove a re-pointed reference and never bring it back.
/// Here the reference is re-pointed from raw tag <c>a</c> to raw tag <c>b</c> (which also moves the
/// effective name, and so the UNS NodeId).
/// </summary>
[Fact]
public void Changed_uns_reference_is_recreated_after_surgical_remove()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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"));
}
/// <summary>
/// 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 <c>UnsReferenceDelta</c> must
/// still be applied, or the UNS mirror keeps serving no history while the raw node serves it — the
/// exact split the <c>Backing*</c> fields exist to close.
/// <para><b>Re-pointed by review I2</b>, 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 <c>UpdateTagAttributes</c> 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.</para>
/// </summary>
[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<AddressSpaceApplier>.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)
}
/// <summary>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.</summary>
[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<AddressSpaceApplier>.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
}
/// <summary>
/// 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
/// <c>EnsureVariable</c> fault silently repaired itself on the next deploy. The delta path removed that
/// accidental self-healing: once <c>_lastApplied</c> records a composition, the failed node is never in
/// a diff set again and <c>EnsureVariable</c>'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
/// <c>failedNodes &gt; 0</c> must NOT advance <c>_lastApplied</c>: the next deploy re-diffs the same
/// composition and re-ensures what failed.
/// </summary>
[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<AddressSpaceApplier>.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);
}
/// <summary>
/// The other half of C1's rule: a REBUILD that also reports failed nodes DOES advance
/// <c>_lastApplied</c>. 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 being an empty-plan no-op.
/// </summary>
[Fact]
public void Rebuild_with_failures_still_advances_last_applied()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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 and adds no sink calls at all.
sink.FailEnsureVariableFor = null;
var dep3 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep3)));
Thread.Sleep(300);
sink.Calls.Count.ShouldBe(afterSecond);
}
/// <summary>Seal a deployment whose raw tree is <c>Plant/Modbus/dev1</c> carrying one tag per name in
/// <paramref name="tagNames"/> (RawPath <c>Plant/Modbus/dev1/&lt;name&gt;</c>). There is no UNS topology, so
/// the composition is raw-only. <paramref name="historizedTag"/> names one tag that carries an
/// <c>isHistorized</c> TagConfig, which is an in-place-updatable (surgical) attribute change.
/// <para><b>Deliberately NOT a <c>params</c> method, and deliberately not overloaded.</b> The first draft
/// was <c>(dbFactory, string? historizedTag = null, params string[] tagNames)</c> alongside a convenience
/// <c>(dbFactory, params string[] tagNames)</c>; <c>Seed(db, "A", "B")</c> 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.</para></summary>
private static Guid SeedRawTagDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
string[] tagNames,
string? historizedTag = null)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
UnsAreas = Array.Empty<object>(),
UnsLines = Array.Empty<object>(),
Equipment = Array.Empty<object>(),
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<object>(),
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<object>(),
ScriptedAlarms = Array.Empty<object>(),
});
return SealArtifact(dbFactory, artifact);
}
/// <summary>Seal a deployment carrying BOTH a raw tree (<c>Plant/Modbus/dev1</c> with tags <c>a</c> +
/// <c>b</c>) and a UNS topology (<c>filling</c> / <c>line1</c> / <paramref name="equipmentName"/>) whose
/// single <c>UnsTagReference</c> row (id <c>ref-1</c>, stable across deploys) points at
/// <paramref name="referencedTag"/>. Re-pointing that row across two deploys yields a
/// <c>UnsReferenceDelta</c>; renaming the equipment yields a rebuild-forcing <c>ChangedEquipment</c>.
/// <paramref name="historianTagnameForA"/> historizes raw tag <c>a</c> with that override, which makes the
/// reference's <c>Backing*</c> mirror fields differ (the 875ec9fe repair path).</summary>
private static Guid SeedUnsRawDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> 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<object>(),
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<object>(),
});
return SealArtifact(dbFactory, artifact);
}
/// <summary>Seal <paramref name="artifact"/> as a new deployment and return its id.</summary>
private static Guid SealArtifact(IDbContextFactory<OtOpcUaConfigDbContext> 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
{
/// <summary>Gets the list of recorded sink calls.</summary>
public ConcurrentQueue<string> Calls { get; } = new();
/// <summary>Gets every EnsureVariable call with the attributes the applier resolved for it (the
/// ordered <see cref="Calls"/> queue only carries node ids).</summary>
public ConcurrentQueue<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> VariableDetails { get; } = new();
/// <summary>Gets every AddReference (Organizes) edge the applier wired.</summary>
public ConcurrentQueue<(string Source, string Target)> ReferenceEdges { get; } = new();
/// <summary>Gets or sets the count of rebuild address space calls.</summary>
public int RebuildCalls;
/// <summary>Gets or sets the node id whose <see cref="EnsureVariable"/> throws (null ⇒ none). The
/// applier's <c>SafeEnsureVariable</c> 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.</summary>
public volatile string? FailEnsureVariableFor;
/// <summary>Gets every surgical in-place tag-attribute update with the attributes the applier resolved
/// for it (the ordered <see cref="Calls"/> queue only carries node ids).</summary>
public ConcurrentQueue<(string NodeId, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> UpdateTagDetails { get; } = new();
/// <summary>Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001).</summary>
public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new();
/// <summary>Gets the list of recorded surgical folder display-name update calls.</summary>
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls => FolderRenameQueue.ToList();
/// <summary>Records a value write call.</summary>
/// <param name="nodeId">The OPC UA node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="ts">The timestamp of the write.</param>
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> Calls.Enqueue($"WV:{nodeId}");
/// <summary>Records an alarm condition write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="ts">The timestamp of the state change.</param>
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}");
/// <summary>Records a materialise-alarm-condition call.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> Calls.Enqueue($"MA:{alarmNodeId}");
/// <summary>Records a folder ensure call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> Calls.Enqueue($"EF:{folderNodeId}");
/// <summary>Records a variable ensure call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, or null if this is a root variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
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));
}
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> ReferenceEdges.Enqueue((sourceNodeId, targetNodeId));
/// <summary>Records a surgical in-place tag-attribute update (always succeeds in this recording sink).</summary>
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;
}
/// <summary>Records a surgical in-place folder display-name update (always succeeds in this recording sink).</summary>
/// <param name="folderNodeId">The folder node ID to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
FolderRenameQueue.Enqueue((folderNodeId, displayName));
return true;
}
/// <summary>Records a surgical variable-node removal (always succeeds in this recording sink).</summary>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
Calls.Enqueue($"RV:{variableNodeId}");
return true;
}
/// <summary>Records a surgical alarm-condition-node removal (always succeeds in this recording sink).</summary>
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
Calls.Enqueue($"RA:{alarmNodeId}");
return true;
}
/// <summary>Records a surgical equipment-subtree removal (always succeeds in this recording sink).</summary>
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
Calls.Enqueue($"RE:{equipmentNodeId}");
return true;
}
}
}