Merge b640-641: native alarm state survives rebuild; projector pruned; AbCip ConditionId=RawPath (#641)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+5
-3
@@ -80,11 +80,13 @@ public class DeferredSinkForwardingReflectionTests
|
||||
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
|
||||
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
|
||||
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
|
||||
// RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in
|
||||
// the realm surface so a future method can't be added that names a node without a realm.
|
||||
// RebuildAddressSpace (it clears BOTH realms) and CompleteAddressSpaceApply (it closes an apply pass,
|
||||
// Gitea #641) are the only node-naming-free methods. This guard locks in the realm surface so a future
|
||||
// method can't be added that names a node without a realm.
|
||||
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
|
||||
{
|
||||
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
|
||||
if (method.Name is nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)
|
||||
or nameof(IOpcUaAddressSpaceSink.CompleteAddressSpaceApply)) continue;
|
||||
|
||||
method.GetParameters().Any(p => CarriesRealm(p.ParameterType)).ShouldBeTrue(
|
||||
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
|
||||
|
||||
@@ -189,6 +189,80 @@ public sealed class AbCipAlarmProjectionTests
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
// Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and
|
||||
// projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every
|
||||
// ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if
|
||||
// it had routed.
|
||||
AlarmEventArgs raised, cleared;
|
||||
lock (events)
|
||||
{
|
||||
raised = events.First(e => e.Message.Contains("raised"));
|
||||
cleared = events.First(e => e.Message.Contains("cleared"));
|
||||
}
|
||||
raised.ConditionId.ShouldBe("HighTemp");
|
||||
raised.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
cleared.ConditionId.ShouldBe("HighTemp");
|
||||
cleared.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the server carries every surviving native condition's state (a client's
|
||||
/// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every
|
||||
/// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so
|
||||
/// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm
|
||||
/// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||||
|
||||
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
await WaitForEventAsync(raises, _ => true);
|
||||
|
||||
// A reconnect / alarm-set change: the same ref, still faulted.
|
||||
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
|
||||
|
||||
lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh
|
||||
/// (inactive) when it came back, so it is raised again.</summary>
|
||||
[Fact]
|
||||
public async Task A_ref_that_left_the_set_and_came_back_is_raised_again()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
await WaitForEventAsync(raises, _ => true);
|
||||
|
||||
await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForEventAsync(raises, _ => raises.Count >= 2);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
|
||||
|
||||
+13
-9
@@ -151,27 +151,31 @@ public sealed class MTConnectAlarmSourceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
|
||||
public async Task A_changed_alarm_set_re_tells_only_conditions_that_left_and_came_back()
|
||||
{
|
||||
// Review MAJOR 2: a changed set is an alarm-bearing delta ⇒ full rebuild ⇒ every condition node was
|
||||
// recreated inactive, so an active condition still in the set must be re-asserted onto it.
|
||||
// Gitea #641: a changed set is an alarm-bearing delta ⇒ a full rebuild, but the server now carries every
|
||||
// surviving condition's state (acknowledgement included) across it. Re-raising a survivor would
|
||||
// UN-acknowledge it, so only the record of a condition that LEFT the set is dropped: one that comes back
|
||||
// was materialised fresh and must be told again.
|
||||
var (driver, agent, events) = await AlarmDriverAsync();
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct);
|
||||
events.Count.ShouldBe(2, "A is re-raised onto its rebuilt node");
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct); // A survives: silent
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // same set: silent
|
||||
events.Count.ShouldBe(1, "a surviving condition is not re-raised");
|
||||
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath], Ct); // A removed
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // A re-added: fresh node
|
||||
events.Count.ShouldBe(2, "a condition that left the set and came back is told again");
|
||||
events.Last().ConditionId.ShouldBe(ConditionRawPath);
|
||||
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct);
|
||||
|
||||
// Positive control: the next real transition is the very next event.
|
||||
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||
events.Select(e => e.Kind).ShouldBe(
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
|
||||
"the same set (a reconnect) must re-raise nothing");
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||
|
||||
await driver.ShutdownAsync(Ct);
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
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 host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Gitea641Test",
|
||||
ApplicationUri = $"urn:OtOpcUa.Gitea641Test:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server.NodeManager!);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
-1
@@ -104,6 +104,27 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
|
||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
|
||||
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
|
||||
[Fact]
|
||||
public void A_decorated_condition_id_is_dropped()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), AlarmRawPath, $"{AlarmRawPath}#active", "ALMD", "decorated", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
// Positive control: the undecorated id routes, and is the first update.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), AlarmRawPath, AlarmRawPath, "ALMD", "plain", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Message.ShouldBe("plain");
|
||||
}
|
||||
|
||||
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
|
||||
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
|
||||
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
|
||||
@@ -409,6 +430,93 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — a redeploy that keeps the alarm must not reset the projector's prior state for it. It used
|
||||
/// to be cleared on EVERY apply, so a driver Acknowledge arriving after any deploy projected
|
||||
/// <c>(prev.Active = false, Acked)</c> and wrote an ACTIVE alarm inactive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_acknowledge_after_a_redeploy_keeps_the_alarm_active()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Raise));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Active.ShouldBeTrue();
|
||||
|
||||
Redeploy(db, actor, publish, coordinator);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Acknowledge));
|
||||
var acked = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
acked.State.Active.ShouldBeTrue("the alarm is still active at the device");
|
||||
acked.State.Acknowledged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 — likewise a Clear after a redeploy must not silently acknowledge an unacknowledged
|
||||
/// alarm (the cleared prior state defaulted Acked to true).</summary>
|
||||
[Fact]
|
||||
public void A_clear_after_a_redeploy_keeps_the_alarm_unacknowledged()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Raise));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
|
||||
|
||||
Redeploy(db, actor, publish, coordinator);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Clear));
|
||||
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
cleared.State.Acknowledged.ShouldBeFalse("nobody acknowledged it");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — a CLIENT acknowledgement is applied to the node by the SDK and only ROUTED here; the
|
||||
/// projector must learn of it. Before #641 the projector was cleared on every deploy, so a Clear arriving
|
||||
/// after one defaulted Acked to true and happened to be right for a client-acked alarm; now that the
|
||||
/// projector survives the deploy, a projector that never heard the ack projects the Clear as UNacked and
|
||||
/// un-acknowledges the alarm the operator acknowledged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_clear_after_a_client_acknowledge_and_a_redeploy_stays_acknowledged()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Raise));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
|
||||
|
||||
// The client's Acknowledge on the node, routed toward the driver (no transition comes back for it).
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "seen", "op"));
|
||||
Redeploy(db, actor, publish, coordinator);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Clear));
|
||||
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
cleared.State.Acknowledged.ShouldBeTrue("the operator acknowledged it before it cleared");
|
||||
}
|
||||
|
||||
private static DriverInstanceActor.AttributeAlarmPublished Transition(AlarmTransitionKind kind) =>
|
||||
new("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "OffNormalAlarm", kind.ToString(), AlarmSeverity.High, Ts,
|
||||
Kind: kind));
|
||||
|
||||
/// <summary>A second deployment carrying the SAME alarm (a new revision of an identical artifact).</summary>
|
||||
private void Redeploy(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, IActorRef actor, TestProbe publish, TestProbe coordinator)
|
||||
{
|
||||
var revB = RevisionHash.Parse(new string('b', 64));
|
||||
var second = SeedV3AlarmDeployment(db, revB, Driver: "drv-1", Tag: "temp_hi");
|
||||
actor.Tell(new DispatchDeployment(second, revB, CorrelationId.NewId()));
|
||||
coordinator.FishForMessage<ApplyAck>(a => a.DeploymentId == second, TimeSpan.FromSeconds(5))
|
||||
.Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
||||
private void SubscribeToAlerts(TestProbe probe)
|
||||
{
|
||||
@@ -439,6 +547,13 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
/// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var (actor, publish, _) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
return (actor, publish);
|
||||
}
|
||||
|
||||
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApplyWithCoordinator(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = CreateTestProbe();
|
||||
@@ -460,7 +575,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
// ExpectMsg<AlarmStateUpdate> assertions see only alarm updates.
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||
|
||||
return (actor, publish);
|
||||
return (actor, publish, coordinator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -24,6 +24,21 @@ public class NativeAlarmProjectorTests
|
||||
snap.Message.ShouldBe("boom");
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 — <c>RetainOnly</c> keeps a still-deployed condition's prior state and forgets a removed
|
||||
/// one's (which then starts from the default inactive + acked).</summary>
|
||||
[Fact]
|
||||
public void RetainOnly_keeps_deployed_conditions_and_forgets_removed_ones()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("kept", Evt(AlarmTransitionKind.Raise));
|
||||
sut.Project("removed", Evt(AlarmTransitionKind.Raise));
|
||||
|
||||
sut.RetainOnly(new HashSet<string>(StringComparer.Ordinal) { "kept" });
|
||||
|
||||
sut.Project("kept", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeTrue();
|
||||
sut.Project("removed", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrigger_projects_active_unacked()
|
||||
{
|
||||
|
||||
@@ -61,6 +61,41 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — every apply ends with <c>CompleteAddressSpaceApply</c>, AFTER the last materialise / announce
|
||||
/// call: that is where the node manager reports the conditions a rebuild carried over (their notifier
|
||||
/// folders are wired by then) and drops any captured state no materialise claimed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_apply_completes_after_every_materialise_pass()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget);
|
||||
sink.CompletedAfterCallCount.Single().ShouldBe(sink.Calls.Count, "nothing may follow the completion");
|
||||
sink.Calls.ShouldContain("NA:line-1");
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 — the raw-sink fallback rebuild (no applier) completes too, so the state it captured
|
||||
/// is discarded rather than left for a later add to resurrect.</summary>
|
||||
[Fact]
|
||||
public void The_raw_sink_fallback_rebuild_completes_the_apply()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Tests that rebuild with no artifact is idempotent no-op.</summary>
|
||||
[Fact]
|
||||
public void Rebuild_with_no_artifact_is_idempotent_no_op()
|
||||
@@ -1053,6 +1088,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
}
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>Gets, per <see cref="CompleteAddressSpaceApply"/> call, how many <see cref="Calls"/> had been
|
||||
/// recorded at that moment — so a test can prove the completion came after every pass (Gitea #641).
|
||||
/// Kept out of <see cref="Calls"/> so existing call-count assertions are unaffected.</summary>
|
||||
public ConcurrentQueue<int> CompletedAfterCallCount { get; } = new();
|
||||
/// <summary>Records the end of an apply pass (Gitea #641).</summary>
|
||||
public void CompleteAddressSpaceApply() => CompletedAfterCallCount.Enqueue(Calls.Count);
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");
|
||||
|
||||
Reference in New Issue
Block a user