00300d0f60
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
232 lines
11 KiB
C#
232 lines
11 KiB
C#
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);
|
|
}
|
|
}
|