fix(opcua): no write ever creates a node; cross-kind id guard (B1)

Review 01 L1/L4/H2. WriteValue/WriteValues are non-creating (an unknown
id is skipped, counted and Debug-logged, as WriteStatuses always was), so
a driver publish racing a surgical remove can no longer resurrect the id
as a flat BaseDataType phantom under the shared root — nor hand a later
re-add of the same tag the phantom's parent/DataType/writability through
EnsureVariable's early return. WriteAlarmCondition for an unmaterialised
id is a logged no-op instead of minting the legacy bool[2] placeholder
(AddPredefinedNode is AddOrUpdate, so it replaced the SDK lookup entry of
any folder sharing the id). The applier no longer writes a condition
state against every removed equipment id (a folder, never a condition).
EnsureFolder/EnsureVariable/MaterialiseAlarmCondition refuse an id already
held by another node kind in the same realm. CreateVariable is deleted.

Tests: NodeManagerPhantomNodeTests (publish racing a remove leaves no
node; the re-add gets the authored shape and is live; unknown alarm id
is a no-op; the guard in all three directions, realm-qualified); the
sink / batch-write / write-statuses tests now declare their variables.

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 06:41:09 -04:00
parent d8c39933ed
commit 00300d0f60
7 changed files with 396 additions and 99 deletions
@@ -45,17 +45,32 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
// ---------------- T2: FailedNodes (removal-pass tally) ----------------
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).</summary>
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).
/// The terminal condition write is made for a removed SCRIPTED ALARM — a removed equipment gets none (review
/// 01 L4: an equipment id is a folder, never a condition, so writing one only minted a phantom).</summary>
[Fact]
public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes()
{
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(AlarmRemovalPlan("alm-1"));
outcome.RemovedNodes.ShouldBe(1);
outcome.FailedNodes.ShouldBe(1);
}
/// <summary>Review 01 L4 — removing an equipment writes NO condition state against the equipment id.</summary>
[Fact]
public void Apply_RemovedEquipment_WritesNoConditionAgainstTheEquipmentId()
{
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(EquipmentRemovalPlan("eq-1"));
outcome.RemovedNodes.ShouldBe(1);
outcome.FailedNodes.ShouldBe(1);
outcome.FailedNodes.ShouldBe(0, "no condition write happens for an equipment folder, so a throwing alarm write cannot be reached");
}
// ---------------- T3: Materialise* passes return failed-node counts ----------------
@@ -135,6 +150,17 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
private static AddressSpacePlan AlarmRemovalPlan(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: ids.Select(id => new ScriptedAlarmPlan(id, "eq-1", "pred", "msg")).ToArray(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
@@ -12,8 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the
/// first poll cycle's force-raise-everything batch from stalling every SDK service thread).
/// <para>These tests pin the <b>observable</b> contract rather than the locking: every item lands with
/// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly
/// as <see cref="OtOpcUaNodeManager.WriteValue"/> does, and an empty batch does nothing at all.
/// its own Value / StatusCode / Timestamp, the realm travels with every item (the same <c>s=</c> id in the
/// two namespaces is two nodes), and an empty batch does nothing at all. An item for an undeclared NodeId
/// is skipped, never created (review 01 L1 — <see cref="NodeManagerPhantomNodeTests"/>).
/// A non-Good status is included so the #519 verbatim-sub-code passthrough is proven on the batched
/// path too — a batch that re-projected status through a 3-state enum would collapse it to a bare
/// <c>Bad</c>.</para>
@@ -90,16 +91,16 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
/// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned
/// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup).</summary>
[Fact]
public async Task WriteValues_creates_missing_variables()
public async Task WriteValues_is_realm_qualified()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
const string SharedId = "Shared/lazy/id";
nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull();
nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull();
const string SharedId = "Shared/both/id";
nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
nm.WriteValues(new List<ValueWrite>
@@ -143,6 +144,8 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc);
nm.EnsureVariable("eq-iso/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-iso/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteValues(new List<ValueWrite>
{
@@ -187,6 +190,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
var items = new List<ValueWrite>(Count);
for (var i = 0; i < Count; i++)
{
nm.EnsureVariable($"eq-chunk/{i}", null, $"{i}", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns));
}
@@ -0,0 +1,231 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Review 01 L1 / L4 / H2 — the node manager never materialises a node from a VALUE or ALARM write, and
/// never lets one <c>(realm, id)</c> be held by two different node kinds.
/// <para>
/// The lazy create-on-first-write path predates <see cref="OtOpcUaNodeManager.EnsureVariable"/> and had
/// no production producer left — but it had a production <i>consumer</i> by accident: a driver publish
/// (or the UNS fan-out) racing a surgical remove re-created the removed id as a flat, read-only,
/// <c>BaseDataType</c> variable under the shared root, and because <c>EnsureVariable</c> early-returns
/// on an existing id, a later re-add of the same tag silently kept the phantom's shape.
/// </para>
/// </summary>
public sealed class NodeManagerPhantomNodeTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-phantom-{Guid.NewGuid():N}");
/// <summary>A value write to an id nothing declared creates nothing.</summary>
[Fact]
public async Task WriteValue_for_an_undeclared_id_creates_no_node()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var before = nm.VariableCount;
nm.WriteValue("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.VariableCount.ShouldBe(before);
nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull();
nm.GetManagerHandle(new NodeId("Plant/Modbus/dev1/Ghost", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw)))
.ShouldBeNull("the SDK must not resolve a node that was never declared");
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A batch naming a removed id skips it (no node, no exception) while the declared ids in the same batch
/// land — the positive control that proves the skip is a skip and not a dropped batch.
/// </summary>
[Fact]
public async Task WriteValues_skips_undeclared_ids_and_still_applies_the_declared_ones()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/Modbus/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
var ts = DateTime.UtcNow;
nm.WriteValues(
[
new ValueWrite("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
]);
nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull();
nm.TryGetVariable("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw)!.Value.ShouldBe(42);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// The L1 scenario end to end: a publish racing a surgical remove leaves NO node, and the re-add that
/// follows gets the AUTHORED DataType, parent and writability rather than inheriting a phantom's.
/// </summary>
[Fact]
public async Task A_publish_racing_a_surgical_remove_leaves_no_node_and_the_readd_gets_the_authored_shape()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
const string id = "Plant/Modbus/dev1/Speed";
nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw);
nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Float", writable: false, realm: AddressSpaceRealm.Raw);
nm.RemoveVariableNode(id, AddressSpaceRealm.Raw).ShouldBeTrue();
// The race: the driver's fan-out publishes the id the surgical remove just dropped.
nm.WriteValue(id, 1.5f, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.TryGetVariable(id, AddressSpaceRealm.Raw).ShouldBeNull("the racing publish must not resurrect the node");
// The re-add (a later deploy re-authors the tag with a different shape).
nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Int32", writable: true, realm: AddressSpaceRealm.Raw);
var v = nm.TryGetVariable(id, AddressSpaceRealm.Raw);
v.ShouldNotBeNull();
v!.DataType.ShouldBe(DataTypeIds.Int32);
v.Parent.ShouldBeSameAs(nm.TryGetFolder("Plant/Modbus/dev1", AddressSpaceRealm.Raw));
((int)v.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe(0, "the re-added node is writable as authored");
v.OnWriteValue.ShouldNotBeNull("a writable node carries the inbound-write handler");
// Positive control: the re-added node is live — a publish lands on it.
nm.WriteValue(id, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
v.Value.ShouldBe(7);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A condition write for an id that was never materialised is a logged no-op (L4): no placeholder variable.</summary>
[Fact]
public async Task WriteAlarmCondition_for_an_unknown_id_creates_nothing()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
var before = nm.VariableCount;
var snapshot = new AlarmConditionSnapshot(
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 0, Message: string.Empty);
// The applier used to write this against the EQUIPMENT id on every removal — a folder, never a condition.
nm.WriteAlarmCondition("eq-1", snapshot, DateTime.UtcNow, AddressSpaceRealm.Uns);
nm.VariableCount.ShouldBe(before);
nm.TryGetVariable("eq-1").ShouldBeNull();
// The folder's SDK lookup entry survives (the phantom used to REPLACE it — AddPredefinedNode is AddOrUpdate).
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns)));
handle.ShouldNotBeNull();
handle!.Node.ShouldBeOfType<FolderState>();
}
finally { await host.DisposeAsync(); }
}
/// <summary>H2 — a variable may not take an id a folder already holds in the same realm.</summary>
[Fact]
public async Task EnsureVariable_refuses_an_id_held_by_a_folder()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1", null, "eq-1", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.TryGetVariable("eq-1").ShouldBeNull();
nm.TryGetFolder("eq-1").ShouldNotBeNull();
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns)));
handle!.Node.ShouldBeSameAs(nm.TryGetFolder("eq-1"));
}
finally { await host.DisposeAsync(); }
}
/// <summary>H2 — a folder may not take an id a variable already holds in the same realm.</summary>
[Fact]
public async Task EnsureFolder_refuses_an_id_held_by_a_variable()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureVariable("Plant/X", null, "X", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureFolder("Plant/X", null, "X", AddressSpaceRealm.Raw);
nm.TryGetFolder("Plant/X", AddressSpaceRealm.Raw).ShouldBeNull();
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("Plant/X", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw)));
handle!.Node.ShouldBeSameAs(nm.TryGetVariable("Plant/X", AddressSpaceRealm.Raw));
}
finally { await host.DisposeAsync(); }
}
/// <summary>H2 — a condition may not take an id a variable already holds in the same realm.</summary>
[Fact]
public async Task MaterialiseAlarmCondition_refuses_an_id_held_by_a_variable()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw);
nm.EnsureVariable("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true);
nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldBeNull();
nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
}
finally { await host.DisposeAsync(); }
}
/// <summary>The same bare id in the OTHER realm is a different node and is still allowed (the guard is realm-qualified).</summary>
[Fact]
public async Task The_guard_is_realm_qualified()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.EnsureFolder("shared", null, "shared", AddressSpaceRealm.Uns);
nm.EnsureVariable("shared", null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.TryGetVariable("shared", AddressSpaceRealm.Raw).ShouldNotBeNull();
nm.TryGetFolder("shared", AddressSpaceRealm.Uns).ShouldNotBeNull();
}
finally { await host.DisposeAsync(); }
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.PhantomTest",
ApplicationUri = $"urn:OtOpcUa.PhantomTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
public void Dispose()
{
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
}
}
@@ -19,8 +19,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
///
/// <para><b>Never creates.</b> A status carries nothing to create a node from, and the caller's
/// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id
/// is skipped, not materialised — the opposite of <see cref="OtOpcUaNodeManager.WriteValues"/>,
/// which lazily creates.</para>
/// is skipped, not materialised — the same contract <see cref="OtOpcUaNodeManager.WriteValues"/>
/// carries since review 01 L1 (it used to lazily create).</para>
///
/// <para>Boots a real <see cref="OtOpcUaSdkServer"/>, like
/// <see cref="NodeManagerBatchWriteTests"/>, so the assertions read the SDK node state a client
@@ -81,6 +81,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
const string sharedId = "Shared/both/realms";
nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
@@ -110,6 +112,7 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
nm.EnsureVariable("Plant/known", null, "known", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
var before = nm.VariableCount;
@@ -143,6 +146,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
nm.EnsureVariable("Plant/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("Plant/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
@@ -20,18 +20,25 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
Path.GetTempPath(),
$"otopcua-sink-{Guid.NewGuid():N}");
/// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
/// <summary>Verifies that WriteValue updates declared variables in the OPC UA node manager (and never
/// creates one — review 01 L1, pinned by <see cref="NodeManagerPhantomNodeTests"/>).</summary>
[Fact]
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
public async Task WriteValue_updates_declared_variables_in_node_manager()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("eq-1/pressure", null, "pressure", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/undeclared", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
nm.VariableCount.ShouldBe(2);
nm.TryGetVariable("eq-1/temp")!.Value.ShouldBe(23.1);
nm.TryGetVariable("eq-1/pressure")!.StatusCode.ShouldBe((StatusCode)StatusCodes.Uncertain);
await host.DisposeAsync();
}
@@ -50,6 +57,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// (a concrete-typed call would simply stop compiling, which is not the regression being pinned).
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
sink.EnsureVariable("Plant/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
sink.EnsureVariable("eq-1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValues(new List<ValueWrite>
{
@@ -91,6 +100,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc);
sink.EnsureVariable("eq-fwd/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("eq-fwd/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
var ex = Should.Throw<ValueWriteBatchException>(() => sink.WriteValues(new List<ValueWrite>
{
@@ -113,18 +124,22 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
}
}
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
/// distinct from value writes.</summary>
/// <summary>A WriteAlarmCondition for an id no condition was materialised under is a no-op (review 01 L4):
/// it creates no placeholder node, and the value write beside it is unaffected.</summary>
[Fact]
public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
public async Task WriteAlarmCondition_for_an_unmaterialised_id_creates_no_node()
{
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
nm.VariableCount.ShouldBe(1);
nm.TryGetVariable("alarm-7").ShouldBeNull();
nm.TryGetAlarmCondition("alarm-7").ShouldBeNull();
await host.DisposeAsync();
}
@@ -242,17 +257,21 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("b", null, "b", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.EnsureVariable("c", null, "c", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(3);
sink.RebuildAddressSpace();
server.NodeManager.VariableCount.ShouldBe(0);
// After rebuild, subsequent writes start fresh.
// After rebuild, subsequent materialisation starts fresh.
sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager.VariableCount.ShouldBe(1);
server.NodeManager.TryGetVariable("a")!.Value.ShouldBe(99);
await host.DisposeAsync();
}