Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
T
Joseph Doherty 482bafdfac feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:

1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
   It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
   lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
   editor, the address picker, the deploy-time inspector and driver init.

Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).

E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:46:48 -04:00

648 lines
33 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// v3 Batch 4 (B4-WP4) — the equipment-tag <b>native-alarm routing</b> wired into
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. A native alarm is a SINGLE
/// Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped
/// equipment-tag NodeId. After an apply, the host's <c>_alarmNodeIdByDriverRef</c> map (built from the
/// alarm-bearing <c>composition.RawTags</c>) resolves <c>(DriverInstanceId, RawPath)</c> to the raw
/// condition NodeId, the <c>NativeAlarmProjector</c> projects the transition into a full
/// <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the publish actor a single
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (Raw realm) — the fan-out to referencing equipment
/// happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root.
///
/// <para>
/// Mirrors the v3 value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
/// carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
/// </para>
/// </summary>
public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-alarm-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag.
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's RawPath (the v3 wire-ref)
/// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
/// distinct <c>SourceNodeId</c> so the lookup is proven to key on <c>ConditionId</c>, not
/// <c>SourceNodeId</c>.</summary>
[Fact]
public void Native_alarm_raise_routes_to_raw_condition_NodeId_active()
{
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(),
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
ConditionId: AlarmRawPath, // the v3 wire-ref RawPath = the lookup key
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise)));
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
update.AlarmNodeId.ShouldBe(AlarmRawPath);
update.Realm.ShouldBe(AddressSpaceRealm.Raw);
update.State.Active.ShouldBeTrue();
update.State.Acknowledged.ShouldBeFalse();
update.TimestampUtc.ShouldBe(Ts);
}
/// <summary>An <see cref="DriverInstanceActor.AttributeAlarmPublished"/> whose <c>ConditionId</c> is not in
/// the alarm map produces NO <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (unknown-ref drop).</summary>
[Fact]
public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
{
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(),
SourceNodeId: "Temp",
ConditionId: "Plant/Modbus/dev1/no_such", // unmapped RawPath ⇒ drop
AlarmType: "OffNormalAlarm",
Message: "nope",
Severity: AlarmSeverity.Low,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise)));
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 <c>"{host}#{type}:{number}"</c> before #647) 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>
[Fact]
public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise)));
// The OPC UA condition update is UNGATED — it must arrive.
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
update.AlarmNodeId.ShouldBe(AlarmRawPath);
// Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.AlarmId.ShouldBe(AlarmRawPath); // the raw condition NodeId (v3: ConditionId == RawPath)
evt.EquipmentPath.ShouldBe(AlarmRawPath); // v3: the alarm meta keys the display off the RawPath
evt.AlarmName.ShouldBe("temp_hi"); // from the raw tag's Name
evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated)
evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType
evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700
evt.Message.ShouldBe("temperature high");
evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user)
evt.HistorizeToAveva.ShouldBeNull(); // absent historizeToAveva key ⇒ null ⇒ default-on
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
}
/// <summary>Native-alarm HistorizeToAveva opt-out: a tag whose <c>TagConfig.alarm</c> carries
/// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate suppresses the
/// durable AVEVA write. The live <c>/alerts</c> fan-out is unaffected.</summary>
[Fact]
public void Native_alarm_historizeToAveva_false_threads_through()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false);
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise)));
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.AlarmId.ShouldBe(AlarmRawPath);
evt.HistorizeToAveva.ShouldBe(false);
}
/// <summary>Native-alarm HistorizeToAveva opt-IN: an explicit <c>historizeToAveva: true</c> rides through as
/// <c>true</c> (distinct from the absent ⇒ null default-on case).</summary>
[Fact]
public void Native_alarm_historizeToAveva_true_threads_through()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true);
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise)));
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.HistorizeToAveva.ShouldBe(true);
}
/// <summary>Secondary suppression: when the cached local role is Secondary the host MUST still write the
/// local OPC UA condition node (ungated — keeps the standby warm) but MUST NOT publish the cluster-wide
/// <c>alerts</c> transition (the Primary publishes the single fleet-wide copy).</summary>
[Fact]
public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
// Mark this node Secondary so the alerts publish is gated off.
TellRedundancyRole(actor, RedundancyRole.Secondary);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise)));
// The OPC UA condition update is UNGATED — it must still arrive on the secondary.
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
update.AlarmNodeId.ShouldBe(AlarmRawPath);
update.State.Active.ShouldBeTrue();
// The cluster-wide alerts publish is gated off on the secondary.
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
/// <c>ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>: <c>Comment</c> carries
/// the operator string and <c>User</c> is <c>"device"</c>.</summary>
[Fact]
public void Native_alarm_operator_comment_flows_to_transition_event()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "investigating",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Acknowledge,
OperatorComment: "investigating")));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.Comment.ShouldBe("investigating");
evt.User.ShouldBe("device");
}
/// <summary>Gitea #626 — a QUALITY-ONLY native event (an <see cref="AlarmTransitionKind.Unspecified"/> kind
/// carrying <see cref="AlarmEventArgs.QualityStatusCode"/>, e.g. an MTConnect condition gone Unavailable while
/// already Normal) re-annotates the condition's Quality through the quality-only node path
/// (<see cref="OpcUaPublishActor.AlarmQualityUpdate"/> — NOT a projected snapshot), and publishes NO
/// <c>alerts</c> row — it is not an alarm transition. The positive control is the Raise that follows: it
/// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed.</summary>
[Fact]
public void Quality_only_native_event_updates_the_condition_but_publishes_no_alerts_row()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "SYSTEM",
Message: "SYSTEM UNAVAILABLE",
Severity: AlarmSeverity.Low,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Unspecified,
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
annotated.AlarmNodeId.ShouldBe(AlarmRawPath);
annotated.Realm.ShouldBe(AddressSpaceRealm.Raw);
annotated.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Positive control: a real transition after it DOES publish, and is the first thing on the topic.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "SYSTEM",
Message: "spindle fault",
Severity: AlarmSeverity.High,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise,
OpcUaSeverity: 800,
QualityStatusCode: OpcUaStatus.Good)));
var raised = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
raised.State.Active.ShouldBeTrue();
raised.State.Severity.ShouldBe((ushort)800);
raised.State.StatusCode.ShouldBe(OpcUaStatus.Good);
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.Message.ShouldBe("spindle fault", "the quality-only annotation must not have published an alerts row");
evt.TransitionKind.ShouldBe("Activated");
evt.Severity.ShouldBe(800);
}
/// <summary>Gitea #626 (review MINOR 1) — after a deploy (which clears the projector's prior state), a
/// quality-only event on a raised, UNACKNOWLEDGED condition must not reach the node as a snapshot: a projected
/// "keep the state" would default to inactive+acked, silently acknowledging it and rewriting its message and
/// severity. Only an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/> (which writes Quality alone) may be
/// sent. Proven by order: the next message after it is the positive control's state update.</summary>
[Fact]
public void Quality_only_event_never_sends_a_state_snapshot_that_could_ack_or_rewrite()
{
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(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Raise, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
// Whatever the projector's prior state is (any apply clears it), a quality-only event must never be
// projected — the routing below is the whole guard, so no deploy needs staging here.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Unspecified, OpcUaSeverity: 800,
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
// Positive control.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "control", AlarmSeverity.High, Ts,
Kind: AlarmTransitionKind.Retrigger, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
var next = publish.FishForMessage(
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
TimeSpan.FromSeconds(5));
next.ShouldBeOfType<OpcUaPublishActor.AlarmQualityUpdate>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
var control = publish.FishForMessage(
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
TimeSpan.FromSeconds(5));
control.ShouldBeOfType<OpcUaPublishActor.AlarmStateUpdate>().State.Message.ShouldBe("control",
"the quality-only event must produce no state snapshot ahead of the control");
}
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
/// is the ONLY signal for a comms-lost native source.</summary>
[Fact]
public void Driver_disconnect_and_reconnect_annotate_native_condition_quality()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
// Comms lost → the raw condition is annotated BadNoCommunication (#519: the reason survives to
// the client; this used to be a bare Bad indistinguishable from any other fault).
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
var bad = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
bad.AlarmNodeId.ShouldBe(AlarmRawPath);
bad.Realm.ShouldBe(AddressSpaceRealm.Raw);
bad.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Comms restored → annotated Good.
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
var good = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
good.AlarmNodeId.ShouldBe(AlarmRawPath);
good.StatusCode.ShouldBe(OpcUaStatus.Good);
}
/// <summary>#477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's
/// conditions (the fan-out is scoped by DriverInstanceId).</summary>
[Fact]
public void Connectivity_for_another_driver_annotates_nothing()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
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)
{
DistributedPubSub.Get(Sys).Mediator.Tell(
new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
}
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own node
/// (<see cref="TestNode"/>) with <paramref name="role"/> so the alerts-publish gate observes it.</summary>
private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
{
host.Tell(new RedundancyStateChanged(
new[]
{
new NodeRedundancyState(
NodeId: TestNode,
Role: role,
IsClusterLeader: role == RedundancyRole.Primary,
IsDriverPrimary: role == RedundancyRole.Primary,
AsOfUtc: DateTime.UtcNow),
},
CorrelationId.NewId()));
}
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied ACK
/// so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the test
/// 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 = this.CreateCleanOutcomePublishProbe();
var mux = CreateTestProbe();
var vtHost = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: mux.Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
// ExpectMsg<AlarmStateUpdate> assertions see only alarm updates.
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish, coordinator);
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// DriverInstance(RawFolderId, Name "Modbus") → Device "dev1" → Tag) with the tag's <c>TagConfig</c>
/// carrying an <c>alarm</c> object so the composer projects a non-null <c>EquipmentTagAlarmInfo</c>
/// (<c>OffNormalAlarm</c> / 700) — making the raw tag a Part 9 condition at its RawPath rather than a
/// value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests
/// (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests).
/// </summary>
private static DeploymentId SeedV3AlarmDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
string Driver, string Tag, bool? historizeToAveva = null)
{
// historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised;
// a concrete true/false writes the bool into the alarm object so the native path threads it.
object alarm = historizeToAveva is { } h
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
: new { alarmType = "OffNormalAlarm", severity = 700 };
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[]
{
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-0",
DeviceId = $"{Driver}:dev1",
TagGroupId = (string?)null,
Name = Tag,
DataType = "Boolean",
AccessLevel = 0, // TagAccessLevel.Read
TagConfig = JsonSerializer.Serialize(new { alarm }),
},
},
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
/// <summary>Minimal <see cref="IAlarmSubscriptionHandle"/> for building <see cref="AlarmEventArgs"/>.</summary>
private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the alarm subscription.</summary>
public string DiagnosticId => "stub-alarm-sub";
}
}