f02067d295
The tests probe a free port (bind 0, release) and start the SDK server later; under a highly parallel run another test can take the port in between and the SDK fails with 'Failed to establish tcp listener sockets for Ipv4 and IPv6.'. OpcUaHostStart.StartAsync builds a fresh host + server on a fresh port per attempt (up to 5) when the failure is a bind failure; all 45 start sites in OpcUaServer.Tests, OpcUaServer.IntegrationTests and Runtime.Tests use it. Per-attempt GUID ApplicationUris are hoisted out of the host factory because a changed URI against a reused PKI store fails with 'Cannot access private key'. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
278 lines
12 KiB
C#
278 lines
12 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #641 — a full <see cref="OtOpcUaNodeManager.RebuildAddressSpace"/> recreates every native condition
|
|
/// inactive, and when the alarm-tag set is unchanged nothing tells the new node its state again (the driver
|
|
/// does not re-subscribe, and Galaxy / OpcUaClient have nothing to replay anyway). The node manager therefore
|
|
/// carries each NATIVE condition's live state across the rebuild: captured before the clear, restored when
|
|
/// the same id is re-materialised native in the same apply, and reported as ONE condition event when the
|
|
/// apply completes (<see cref="OtOpcUaNodeManager.CompleteAddressSpaceApply"/>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// "One event" is observed here through the condition's <c>EventId</c>, which every report stamps fresh.
|
|
/// The exact count over the wire is pinned by <c>NativeAlarmRebuildCarryOverWireTests</c>.
|
|
/// </remarks>
|
|
public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
|
{
|
|
private const string Device = "Plant/MTConnect/dev1";
|
|
private const string Alarm = "Plant/MTConnect/dev1/Xtravel";
|
|
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-641-{Guid.NewGuid():N}");
|
|
|
|
private static AlarmConditionSnapshot Fault(bool acked) => new(
|
|
Active: true, Acknowledged: acked, Confirmed: true, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: "Xtravel FAULT 1001",
|
|
StatusCode: OpcUaStatus.Good);
|
|
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task An_active_unacked_native_condition_survives_a_rebuild_and_is_reported_once()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
|
Materialise(nm, native: true);
|
|
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
|
|
|
|
nm.RebuildAddressSpace();
|
|
Materialise(nm, native: true);
|
|
var restored = Condition(nm);
|
|
var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone();
|
|
|
|
// State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it.
|
|
restored.ActiveState.Id.Value.ShouldBeTrue();
|
|
restored.AckedState.Id.Value.ShouldBeFalse();
|
|
restored.Retain.Value.ShouldBeTrue();
|
|
restored.Severity.Value.ShouldBe((ushort)800);
|
|
restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001");
|
|
restored.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
// The report happens at the END of the apply (after the notifier wiring), stamped with the instant the
|
|
// state came about, not the deploy time.
|
|
restored.EventId.Value.ShouldNotBe(idBeforeComplete);
|
|
restored.Time.Value.ShouldBe(raisedAt);
|
|
|
|
// ...and only once: a second completion with nothing pending reports nothing.
|
|
var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone();
|
|
nm.CompleteAddressSpaceApply();
|
|
restored.EventId.Value.ShouldBe(idAfterComplete);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The acknowledgement a CLIENT applied lives only on the node (the SDK applied it; no driver or projector
|
|
/// saw it) — the reason the carry-over reads the node rather than any host-side cache.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task A_client_acknowledgement_survives_the_rebuild()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
Materialise(nm, native: true);
|
|
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
// What the SDK's Acknowledge handler does to the node; nothing else hears of it.
|
|
Condition(nm).SetAcknowledgedState(nm.SystemContext, true);
|
|
|
|
nm.RebuildAddressSpace();
|
|
Materialise(nm, native: true);
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
var restored = Condition(nm);
|
|
restored.ActiveState.Id.Value.ShouldBeTrue();
|
|
restored.AckedState.Id.Value.ShouldBeTrue();
|
|
restored.Retain.Value.ShouldBeTrue();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #641 review — the comment a client left with its acknowledgement is condition state too (native
|
|
/// conditions support AddComment), and an event the condition reported AFTER the restore (a client method
|
|
/// call landing on an SDK thread before the apply completes) must not have its newer <c>Time</c> rolled
|
|
/// back by the deferred restore report.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task The_comment_is_carried_and_a_newer_report_is_not_rolled_back()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
|
Materialise(nm, native: true);
|
|
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
|
|
Condition(nm).Comment.Value = new LocalizedText("on it");
|
|
|
|
nm.RebuildAddressSpace();
|
|
Materialise(nm, native: true);
|
|
var later = raisedAt.AddMinutes(5);
|
|
nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later`
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
var restored = Condition(nm);
|
|
restored.Comment.Value.Text.ShouldBe("on it");
|
|
restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time");
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>Falsification control: a condition in the no-event position comes back exactly as a fresh
|
|
/// materialise builds it, and reports nothing.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task A_normal_native_condition_reports_nothing_and_keeps_the_fresh_state()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
Materialise(nm, native: true);
|
|
nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { Active = false, Acknowledged = true, Message = "cleared" },
|
|
DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
nm.RebuildAddressSpace();
|
|
Materialise(nm, native: true);
|
|
var fresh = Condition(nm);
|
|
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
fresh.EventId.Value.ShouldBe(idBefore);
|
|
fresh.ActiveState.Id.Value.ShouldBeFalse();
|
|
fresh.AckedState.Id.Value.ShouldBeTrue();
|
|
fresh.Retain.Value.ShouldBeFalse();
|
|
fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message");
|
|
fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity");
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The source-data quality is carried for a NORMAL condition too, silently: quality is annotated only on a
|
|
/// driver's connectivity edge, so without the carry a rebuild left every normal native condition reading
|
|
/// <c>BadWaitingForInitialData</c> until the driver next flapped.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task A_normal_condition_keeps_its_quality_without_an_event()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
Materialise(nm, native: true);
|
|
nm.WriteAlarmQuality(Alarm, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
nm.RebuildAddressSpace();
|
|
Materialise(nm, native: true);
|
|
var fresh = Condition(nm);
|
|
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
|
fresh.EventId.Value.ShouldBe(idBefore);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>A condition the rebuild did NOT re-create has its captured state discarded when the apply
|
|
/// completes, so a later deploy that adds the same RawPath back gets a fresh node, not a resurrection.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task A_condition_removed_by_the_rebuild_is_not_resurrected_by_a_later_add()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
Materialise(nm, native: true);
|
|
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
nm.RebuildAddressSpace();
|
|
nm.CompleteAddressSpaceApply(); // the alarm was removed: nothing re-materialised it
|
|
|
|
Materialise(nm, native: true); // a later, surgical re-add of the same RawPath
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
var readded = Condition(nm);
|
|
readded.ActiveState.Id.Value.ShouldBeFalse();
|
|
readded.AckedState.Id.Value.ShouldBeTrue();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>A native condition re-materialised as SCRIPTED under the same id is a different alarm: nothing
|
|
/// is restored onto it (and the scripted engine's own #487 re-assert owns scripted state).</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task A_kind_swap_on_the_same_id_is_not_restored()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
Materialise(nm, native: true);
|
|
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
nm.RebuildAddressSpace();
|
|
Materialise(nm, native: false);
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>Scripted conditions are not captured — <c>ScriptedAlarmHostActor.ReassertConditionNodes</c>
|
|
/// (#487) already re-projects them after a reload, and a second source would double-report.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public async Task A_scripted_condition_is_not_carried()
|
|
{
|
|
var (host, nm) = await BootAsync();
|
|
Materialise(nm, native: false);
|
|
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
nm.RebuildAddressSpace();
|
|
Materialise(nm, native: false);
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
private static void Materialise(OtOpcUaNodeManager nm, bool native)
|
|
{
|
|
nm.EnsureFolder(Device, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
nm.MaterialiseAlarmCondition(Alarm, Device, "Xtravel", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: native);
|
|
}
|
|
|
|
private static Opc.Ua.AlarmConditionState Condition(OtOpcUaNodeManager nm) =>
|
|
nm.TryGetAlarmCondition(Alarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootAsync()
|
|
{
|
|
var applicationUri = $"urn:OtOpcUa.Gitea641Test:{Guid.NewGuid():N}";
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.Gitea641Test",
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
var host = started.Host;
|
|
var server = started.Server;
|
|
return (host, server.NodeManager!);
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|