4624141a5d
tag gate, and fix three silently-broken OpcUaClient integration tests
Continues working through deferment.md's remaining items.
SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
- REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
E2E. The behaviour never went dark, only the v2 seeding did.
- FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
already compared RawTagPlan record-equal and RawTagPlan carries array /
historize / alarm intent, so widening its corpus by two tags covers what
three empty placeholder suites had been promising. Those suites are deleted.
- DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
and both dot-joint {{equip}}.X token suites (that resolver was deleted in
Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.
G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.
OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.
DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
698 lines
37 KiB
C#
698 lines
37 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;
|
|
|
|
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<byte>()</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;
|
|
}
|
|
|
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
|
{
|
|
/// <summary>Gets the list of recorded sink calls.</summary>
|
|
public ConcurrentQueue<string> Calls { get; } = new();
|
|
/// <summary>Gets or sets the count of rebuild address space calls.</summary>
|
|
public int RebuildCalls;
|
|
/// <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, OpcUaQuality quality, 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, OpcUaQuality quality, 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)
|
|
=> Calls.Enqueue($"EV:{variableNodeId}");
|
|
/// <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") { }
|
|
/// <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}");
|
|
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;
|
|
}
|
|
}
|
|
}
|