feat(scripted-alarms): fire Part 9 condition events on transition (T16)
This commit is contained in:
@@ -264,6 +264,96 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T16 — each engine-driven <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/> on a
|
||||
/// materialised condition fires a real Part 9 condition event, stamping a FRESH per-event
|
||||
/// <c>EventId</c> (GUID bytes). Part 9 requires a unique EventId per event so inbound
|
||||
/// Acknowledge/Confirm (T17) can correlate back to the exact event being acked.
|
||||
/// <para>
|
||||
/// We assert EventId-freshness (non-null + changed from its materialise-time value, and a second
|
||||
/// write yields a DIFFERENT EventId) plus no-throw. The node manager exposes no in-process hook to
|
||||
/// observe <c>ReportEvent</c> delivery without standing up a full client subscription + monitored
|
||||
/// item, so actual event DELIVERY to a subscribing client is proven live in T19 (Client.CLI) rather
|
||||
/// than faked here — see the comment below.
|
||||
/// </para></summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_fires_event_with_fresh_EventId_per_transition()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt");
|
||||
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[]
|
||||
// reference is reused, so we compare snapshots, not the same array instance.
|
||||
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
||||
|
||||
// First engine-driven transition → fires an event with a fresh EventId.
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
|
||||
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
firstEventId.ShouldNotBeNull();
|
||||
firstEventId!.Length.ShouldBe(16); // GUID bytes
|
||||
// Changed from the materialise-time value (a real event fired, not the create-time stamp).
|
||||
if (materialiseEventId is not null)
|
||||
{
|
||||
firstEventId.ShouldNotBe(materialiseEventId);
|
||||
}
|
||||
|
||||
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow);
|
||||
|
||||
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
secondEventId.ShouldNotBeNull();
|
||||
secondEventId!.ShouldNotBe(firstEventId);
|
||||
|
||||
// NOTE: event DELIVERY (a subscribing client actually receiving these ActiveState/AckedState
|
||||
// transitions on the equipment folder) is an integration concern proven LIVE in T19 via the
|
||||
// Client.CLI alarm subscription — not asserted here, and deliberately NOT faked. This unit-level
|
||||
// test proves the firing path is exercised (no throw) and the Part 9 EventId-freshness invariant
|
||||
// that T17's ack routing depends on.
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>T16 — firing an event must not break the state projection even when ReportEvent could
|
||||
/// fail. We can't easily force ReportEvent to throw in-process, but we CAN prove the projection +
|
||||
/// firing path is exception-safe end-to-end across repeated transitions and that the condition's
|
||||
/// mandatory state stays correct after firing.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_firing_does_not_disturb_projected_state()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2");
|
||||
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
||||
Should.NotThrow(() =>
|
||||
{
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||
});
|
||||
|
||||
// Final projected state is intact after the last firing.
|
||||
condition!.ActiveState.Id.Value.ShouldBeFalse();
|
||||
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||
condition.Message.Value.Text.ShouldBe("cleared");
|
||||
condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each call
|
||||
/// site only specifies the fields it cares about.</summary>
|
||||
private static AlarmConditionSnapshot Snapshot(
|
||||
|
||||
Reference in New Issue
Block a user