ddd7678269
A full RebuildAddressSpace recreated every native Part 9 condition inactive + acknowledged, and when the alarm-tag set was unchanged (e.g. one UnsTagReference added to an alarm-bearing raw tag) nothing re-told the new node its state: the host does not re-subscribe for an unchanged set, and Galaxy / OpcUaClient have no snapshot to replay anyway. Active alarms read normal until their next device transition. OtOpcUaNodeManager now captures each NATIVE condition's live state (from the node, so a client-applied ack is kept) before the rebuild clears, restores it when the same id is re-materialised native in the same apply, and reports ONE condition event per restored off-normal condition at the new IOpcUaAddressSpaceSink.CompleteAddressSpaceApply (after notifier wiring). Unclaimed captures are discarded there, so a later re-add is fresh. Quality is carried silently for normal conditions too (it is only re-annotated on a connectivity edge). Scripted conditions are untouched (#487 re-asserts them). OpcUaPublishActor calls CompleteAddressSpaceApply at the end of every apply. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
126 lines
6.1 KiB
C#
126 lines
6.1 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #641 over the wire — a full address-space rebuild that leaves the native alarm set unchanged must not
|
|
/// leave an active condition reading inactive. A Server-object (<c>i=2253</c>) item, which outlives the rebuild
|
|
/// (#586), receives exactly ONE condition event carrying the restored state — and an acknowledgement a client
|
|
/// applied before the rebuild survives it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The rebuild is driven exactly as <c>OpcUaPublishActor</c> drives one: <c>RebuildAddressSpace</c>, the
|
|
/// re-materialise + notifier wiring, then <c>CompleteAddressSpaceApply</c>. The count is read after a later
|
|
/// sentinel transition arrives on the same item, so a duplicate would already be queued ahead of it.
|
|
/// The native ack router echoes nothing back, like MTConnect and FOCAS: the acknowledgement lives only on the node.
|
|
/// </remarks>
|
|
public sealed class NativeAlarmRebuildCarryOverWireTests
|
|
{
|
|
private const NodePermissions All =
|
|
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
|
| NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
|
|
|
|
private static AlarmConditionSnapshot Fault(string message) => new(
|
|
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: message);
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task A_rebuild_with_an_unchanged_alarm_set_reports_the_restored_state_once(bool clientAcked)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync($"Rebuild641{clientAcked}", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.Nm.NativeAlarmAckRouter = _ => { }; // a driver whose ack is a no-op: nothing echoes back
|
|
rig.MaterialiseNative();
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Project(native: true, Fault("fault"));
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "fault"));
|
|
var expectedBefore = 1;
|
|
|
|
if (clientAcked)
|
|
{
|
|
await session.CallAsync(
|
|
ConditionNodeId(session, native: true), MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
|
|
events.LatestEventId(serverItem, "fault"), new LocalizedText("ack"));
|
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(serverItem, "fault").Count == 2);
|
|
expectedBefore = 2;
|
|
}
|
|
|
|
// The deploy: a rebuild whose composition carries the same alarm (e.g. one UnsTagReference added).
|
|
rig.Sink.RebuildAddressSpace();
|
|
rig.MaterialiseNative();
|
|
rig.Sink.CompleteAddressSpaceApply();
|
|
|
|
rig.Project(native: true, Fault("sentinel") with { Acknowledged = clientAcked });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
|
|
|
|
var faultEvents = events.ConditionEvents(serverItem, "fault");
|
|
faultEvents.Count.ShouldBe(expectedBefore + 1, "the pre-rebuild event(s) plus exactly one restored event");
|
|
var restored = faultEvents[^1];
|
|
restored[RecordedEvents.ActiveField].Value.ShouldBe(true);
|
|
restored[RecordedEvents.AckedField].Value.ShouldBe(clientAcked);
|
|
restored[RecordedEvents.RetainField].Value.ShouldBe(true);
|
|
restored[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800);
|
|
|
|
// And a ConditionRefresh-style read of the live node agrees.
|
|
var active = await session.ReadValueAsync(
|
|
new NodeId($"{RawAlarmPath}/ActiveState/Id", ConditionNodeId(session, native: true).NamespaceIndex), ct);
|
|
active.Value.ShouldBe(true);
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>Falsification control: a NORMAL native condition produces no event across the rebuild.</summary>
|
|
[Fact]
|
|
public async Task A_rebuild_reports_nothing_for_a_normal_condition()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("Rebuild641Normal", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseNative();
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Project(native: true, Fault("cleared") with { Active = false, Acknowledged = true });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "cleared"));
|
|
|
|
rig.Sink.RebuildAddressSpace();
|
|
rig.MaterialiseNative();
|
|
rig.Sink.CompleteAddressSpaceApply();
|
|
|
|
rig.Project(native: true, Fault("sentinel"));
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
|
|
|
|
events.ConditionEvents(serverItem, "cleared").Count.ShouldBe(1);
|
|
events.ConditionEvents(serverItem, "temp_hi").ShouldBeEmpty("the fresh node is not reported");
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
}
|