fix(opcua): preserve repeated alarm methods and owned write reverts
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class Sdk2ConditionMethodCompatibilityTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-methods-{Guid.NewGuid():N}");
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private OtOpcUaSdkServer _server = null!;
|
||||
private OtOpcUaNodeManager _manager = null!;
|
||||
private readonly List<AlarmCommand> _scripted = [];
|
||||
private readonly List<NativeAlarmAck> _native = [];
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "SDK2.MethodContracts", ApplicationUri = "urn:SDK2.MethodContracts",
|
||||
OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pki,
|
||||
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), TestContext.Current.CancellationToken);
|
||||
_host = started.Host;
|
||||
_server = started.Server;
|
||||
_manager = _server.NodeManager!;
|
||||
_manager.AlarmCommandRouter = _scripted.Add;
|
||||
_manager.NativeAlarmAckRouter = _native.Add;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _host.DisposeAsync();
|
||||
Directory.Delete(_pki, true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, "again")]
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, "again")]
|
||||
public void Actual_SDK_method_preserves_current_trunk_reack_and_comment(bool native, string? comment)
|
||||
{
|
||||
var condition = Condition(native);
|
||||
Call(condition, condition.Acknowledge!, condition.EventId!.Value, "first").ShouldBe(ServiceResult.Good);
|
||||
if (!native) Call(condition, condition.Confirm!, condition.EventId.Value, "confirmed").ShouldBe(ServiceResult.Good);
|
||||
var before = condition.EventId.Value;
|
||||
Call(condition, condition.Acknowledge!, before, comment).ShouldBe(ServiceResult.Good);
|
||||
condition.AckedState!.Id!.Value.ShouldBeTrue();
|
||||
condition.ConfirmedState!.Id!.Value.ShouldBeTrue();
|
||||
if (comment is null) condition.EventId.Value.ShouldBe(before);
|
||||
else
|
||||
{
|
||||
condition.EventId.Value.ShouldNotBe(before);
|
||||
condition.Comment!.Value.Text.ShouldBe(comment);
|
||||
}
|
||||
if (native) _native.Count.ShouldBe(2, "a native re-ack is an upstream retry");
|
||||
else _scripted.Count(x => x.Operation == "Acknowledge").ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Actual_SDK_native_confirm_keeps_the_application_unsupported_status()
|
||||
{
|
||||
var condition = Condition(native: true);
|
||||
var before = condition.EventId!.Value;
|
||||
Call(condition, condition.Confirm!, before, null).StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
||||
condition.ConfirmedState!.Id!.Value.ShouldBeTrue();
|
||||
condition.EventId.Value.ShouldBe(before);
|
||||
_native.ShouldBeEmpty();
|
||||
_scripted.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_trunk_reack_still_requires_app_authority_primary_and_native_preflight()
|
||||
{
|
||||
var condition = Condition(native: true);
|
||||
Call(condition, condition.Acknowledge!, condition.EventId!.Value, null).ShouldBe(ServiceResult.Good);
|
||||
var before = condition.EventId.Value;
|
||||
Call(condition, condition.Acknowledge!, before, null, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
_manager.NativeAlarmAckPrimaryGate = () => false;
|
||||
Call(condition, condition.Acknowledge!, before, null).StatusCode.ShouldBe(StatusCodes.BadInvalidState);
|
||||
_manager.NativeAlarmAckPrimaryGate = () => true;
|
||||
_manager.NativeAlarmAckPreflight = _ => "driver unavailable";
|
||||
Call(condition, condition.Acknowledge!, before, null).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
condition.EventId.Value.ShouldBe(before);
|
||||
_native.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("empty")]
|
||||
[InlineData("foreign")]
|
||||
[InlineData("superseded")]
|
||||
[InlineData("disabled")]
|
||||
public void Actual_SDK_method_keeps_event_and_enabled_guards(string invalid)
|
||||
{
|
||||
var condition = Condition(native: false);
|
||||
var original = condition.EventId!.Value;
|
||||
Call(condition, condition.Acknowledge!, original, null).ShouldBe(ServiceResult.Good);
|
||||
var current = condition.EventId.Value;
|
||||
var token = invalid switch
|
||||
{
|
||||
"empty" => default,
|
||||
"foreign" => ByteString.From(Guid.NewGuid().ToByteArray()),
|
||||
"superseded" => original,
|
||||
_ => current,
|
||||
};
|
||||
if (invalid == "disabled") condition.SetEnableState(Context(), false);
|
||||
var routes = _scripted.Count;
|
||||
ServiceResult.IsBad(Call(condition, condition.Acknowledge!, token, null)).ShouldBeTrue();
|
||||
_scripted.Count.ShouldBe(routes);
|
||||
condition.EventId.Value.ShouldBe(current);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OffNormalAlarm")]
|
||||
[InlineData("DiscreteAlarm")]
|
||||
[InlineData("AlarmCondition")]
|
||||
public void An_acknowledged_branch_is_constructible_and_does_not_get_the_trunk_repeat_waiver(string type)
|
||||
{
|
||||
var condition = Condition(native: false, type);
|
||||
var branch = condition.CreateBranch(Context(), new NodeId(Guid.NewGuid())).ShouldBeAssignableTo<AlarmConditionState>()!;
|
||||
branch.EventId!.Value = ByteString.From(Guid.NewGuid().ToByteArray());
|
||||
branch.SetAcknowledgedState(Context(), true);
|
||||
var routes = _scripted.Count;
|
||||
Call(condition, condition.Acknowledge!, branch.EventId!.Value, null).StatusCode.ShouldBe(StatusCodes.BadConditionBranchAlreadyAcked);
|
||||
_scripted.Count.ShouldBe(routes);
|
||||
}
|
||||
|
||||
private AlarmConditionState Condition(bool native, string type = "OffNormalAlarm")
|
||||
{
|
||||
var realm = native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
|
||||
_manager.EnsureFolder("eq", null, "Equipment", realm);
|
||||
_manager.MaterialiseAlarmCondition("alarm", "eq", "Alarm", type, 700, realm, isNative: native);
|
||||
_manager.WriteAlarmCondition("alarm", new AlarmConditionSnapshot(true, false, true, true,
|
||||
AlarmShelvingKind.Unshelved, 700, "active"), DateTime.UtcNow, realm);
|
||||
return _manager.TryGetAlarmCondition("alarm", realm)!;
|
||||
}
|
||||
|
||||
private ServerSystemContext Context(bool authorized = true) => new(_server.CurrentInstance)
|
||||
{
|
||||
UserIdentity = new RoleCarryingUserIdentity(new AnonymousIdentityToken(),
|
||||
authorized ? [OpcUaDataPlaneRoles.AlarmAck] : [], "operator", null),
|
||||
};
|
||||
|
||||
private ServiceResult Call(AlarmConditionState condition, AddCommentMethodState method,
|
||||
ByteString eventId, string? comment, bool authorized = true)
|
||||
{
|
||||
var argumentErrors = new List<ServiceResult>();
|
||||
var result = method.Call(Context(authorized), condition.NodeId,
|
||||
[Variant.From(eventId), Variant.From(comment is null ? default(LocalizedText) : new LocalizedText(comment))],
|
||||
argumentErrors, new List<Variant>());
|
||||
argumentErrors.All(ServiceResult.IsGood).ShouldBeTrue();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
@@ -15,6 +16,7 @@ public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime
|
||||
private OtOpcUaSdkServer _server = null!;
|
||||
private OtOpcUaNodeManager _manager = null!;
|
||||
private readonly RecordingGateway _gateway = new();
|
||||
private readonly OutcomeLogger _outcomeLogger = new();
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
@@ -24,7 +26,7 @@ public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime
|
||||
{
|
||||
ApplicationName = "SDK2.ValueContracts", ApplicationUri = "urn:SDK2.ValueContracts",
|
||||
PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki,
|
||||
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), Ct);
|
||||
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(_outcomeLogger), Ct);
|
||||
_host = started.Host;
|
||||
_server = started.Server;
|
||||
_manager = _server.NodeManager!;
|
||||
@@ -214,6 +216,123 @@ public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime
|
||||
scalarBytes.TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AddressSpaceRealm.Raw)]
|
||||
[InlineData(AddressSpaceRealm.Uns)]
|
||||
public async Task An_immediate_failed_array_outcome_reverts_an_actual_node_write(AddressSpaceRealm realm)
|
||||
{
|
||||
var node = Node("Int16", array: true, realm: realm);
|
||||
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, realm);
|
||||
_manager.NodeWriteGateway = new OutcomeGateway(_ => Task.FromResult(new NodeWriteOutcome(false, "refused")));
|
||||
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline && !SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().SequenceEqual(new short[] { 7, 8 }))
|
||||
await Task.Delay(10, Ct);
|
||||
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([7, 8]);
|
||||
node.StatusCode.ShouldBe(StatusCodes.Good);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("equal-client-write")]
|
||||
[InlineData("equal-driver-poll")]
|
||||
[InlineData("status-only")]
|
||||
[InlineData("same-id-recreated")]
|
||||
[InlineData("shape-update")]
|
||||
public async Task An_old_failed_outcome_cannot_replace_a_newer_actual_node_tuple(string change)
|
||||
{
|
||||
var node = Node("Int16", array: true);
|
||||
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
var first = new TaskCompletionSource<NodeWriteOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var calls = 0;
|
||||
_manager.NodeWriteGateway = new OutcomeGateway(_ => ++calls == 1
|
||||
? first.Task : Task.FromResult(new NodeWriteOutcome(true, null)));
|
||||
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||
var stamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
switch (change)
|
||||
{
|
||||
case "equal-client-write": Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); break;
|
||||
case "equal-driver-poll":
|
||||
_manager.WriteValue("v", new short[] { 1, 2 }, StatusCodes.UncertainLastUsableValue.Code, stamp, AddressSpaceRealm.Raw); break;
|
||||
case "status-only":
|
||||
_manager.WriteStatuses([new StatusWrite("v", StatusCodes.BadNoCommunication.Code, stamp, AddressSpaceRealm.Raw)]); break;
|
||||
case "same-id-recreated":
|
||||
_manager.RemoveVariableNode("v", AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
node = Node("Int16", array: true);
|
||||
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); break;
|
||||
case "shape-update":
|
||||
_manager.UpdateTagAttributes("v", true, null, "Int32", true, 2, AddressSpaceRealm.Raw).ShouldBeTrue(); break;
|
||||
}
|
||||
var expected = node.Value;
|
||||
var status = node.StatusCode;
|
||||
var time = node.Timestamp;
|
||||
first.SetResult(new NodeWriteOutcome(false, "older write failed"));
|
||||
await _outcomeLogger.Ignored.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
|
||||
node.Value.ShouldBe(expected);
|
||||
node.StatusCode.ShouldBe(status);
|
||||
node.Timestamp.ShouldBe(time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reentrant_driver_publication_wins_over_the_callback_optimistic_commit()
|
||||
{
|
||||
var node = Node("Int16", array: true);
|
||||
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
var first = new TaskCompletionSource<NodeWriteOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var stamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
_manager.NodeWriteGateway = new OutcomeGateway(_ =>
|
||||
{
|
||||
_manager.WriteValue("v", new short[] { 1, 2 }, StatusCodes.UncertainLastUsableValue.Code, stamp, AddressSpaceRealm.Raw);
|
||||
return first.Task;
|
||||
});
|
||||
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||
node.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||
node.Timestamp.ToDateTime().ShouldBe(stamp);
|
||||
first.SetResult(new NodeWriteOutcome(false, "old write failed"));
|
||||
await _outcomeLogger.Ignored.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
|
||||
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([1, 2]);
|
||||
node.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||
node.Timestamp.ToDateTime().ShouldBe(stamp);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_refused_attempt_does_not_disown_an_older_committed_write(bool synchronousThrow)
|
||||
{
|
||||
var node = Node("Int16", array: true);
|
||||
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
var first = new TaskCompletionSource<NodeWriteOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_manager.NodeWriteGateway = new OutcomeGateway(_ => first.Task);
|
||||
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||
if (synchronousThrow)
|
||||
{
|
||||
_manager.NodeWriteGateway = new OutcomeGateway(_ => throw new InvalidOperationException("gateway threw"));
|
||||
Write(node, new short[] { 3, 4 }).StatusCode.ShouldBe(StatusCodes.BadDeviceFailure);
|
||||
}
|
||||
else Write(node, new short[] { 3, 4 }, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
first.SetResult(new NodeWriteOutcome(false, "first write failed"));
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline && !SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().SequenceEqual(new short[] { 7, 8 }))
|
||||
await Task.Delay(10, Ct);
|
||||
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([7, 8]);
|
||||
}
|
||||
|
||||
private sealed class OutcomeLogger : ILogger<OtOpcUaNodeManager>
|
||||
{
|
||||
internal TaskCompletionSource Ignored { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel level) => true;
|
||||
public void Log<TState>(LogLevel level, EventId id, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (formatter(state, exception).Contains("ignored superseded write outcome", StringComparison.Ordinal)) Ignored.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OutcomeGateway(Func<object?, Task<NodeWriteOutcome>> outcome) : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => outcome(value);
|
||||
}
|
||||
|
||||
private BaseDataVariableState Node(string type, bool array = false, bool refuse = false,
|
||||
bool writable = true, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
|
||||
{
|
||||
|
||||
@@ -312,7 +312,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var condition = nm.TryGetAlarmCondition("alm-1");
|
||||
condition.ShouldNotBeNull();
|
||||
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
||||
condition.ShouldBeOfType<OffNormalAlarmState>();
|
||||
condition.ShouldBeAssignableTo<OffNormalAlarmState>();
|
||||
// v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the
|
||||
// UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports.
|
||||
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
||||
|
||||
Reference in New Issue
Block a user