f54922f94c
Residuals of #760: 1. An ack routed to a disabled / not-yet-spawned driver, or to a stubbed child (driver does not run on this node, or its factory rejected the config), was dropped and left the condition Acknowledged. DriverHostActor now reverts it through the same NativeAlarmProjector.TryRevertAcknowledge path as a refused ack. 2. A native ack arriving while the host is Stale was dropped before the projector recorded it, so it could never be reverted. Stale now marks the whole NativeAlarmAckPreflightView unavailable (SetHostUnavailable), so the node manager refuses every native ack synchronously; BecomeSteady clears it. 3. (keyed sessions without a Galaxy identity) is not addressed. Tests: DriverHostActorNativeAlarmTests' client-ack test and the FOCAS respawn theory now start the host's own driver (AckingAlarmDriver; a factory building a real FocasDriver per spawn over per-instance fake CNCs, with a real config-change respawn) - both fail against the fix in their old shape. New: ack to a disabled driver reverts, ack to a stubbed driver reverts, a Stale host refuses through the pre-check view - all three fail with the fix mutated out. Runtime.Tests 943/0/3. Docs: AlarmTracking.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
659 lines
34 KiB
C#
659 lines
34 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.
|
|
/// <para>
|
|
/// lmxopcua#765 — the host's own driver is started here: an ack routed to a driver that is not running
|
|
/// is now reverted (it reached no alarm system), so only a delivered ack may stand.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_clear_after_a_client_acknowledge_and_a_redeploy_stays_acknowledged()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new AckingAlarmDriverFactory("Modbus");
|
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", enabled: true);
|
|
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId, factory);
|
|
// An ack reaching a still-Connecting child is dropped (and now reverted); wait for Connected.
|
|
AwaitCondition(() => factory.Driver.AlarmsSubscribed, TimeSpan.FromSeconds(5));
|
|
|
|
actor.Tell(Transition(AlarmTransitionKind.Raise));
|
|
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5))
|
|
.State.Acknowledged.ShouldBeFalse();
|
|
|
|
// The client's Acknowledge on the node, routed to the driver (no transition comes back for it).
|
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "seen", "op"));
|
|
AwaitCondition(() => factory.Driver.Acks == 1, TimeSpan.FromSeconds(5));
|
|
Redeploy(db, actor, publish, coordinator, enabled: true);
|
|
|
|
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,
|
|
bool enabled = false)
|
|
{
|
|
var revB = RevisionHash.Parse(new string('b', 64));
|
|
var second = SeedV3AlarmDeployment(db, revB, Driver: "drv-1", Tag: "temp_hi", enabled: enabled);
|
|
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, IDriverFactory? driverFactory = null)
|
|
{
|
|
var coordinator = CreateTestProbe();
|
|
var publish = this.CreateCleanOutcomePublishProbe();
|
|
var mux = CreateTestProbe();
|
|
var vtHost = CreateTestProbe();
|
|
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
driverFactory: driverFactory,
|
|
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, bool enabled = false)
|
|
{
|
|
// 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 = enabled },
|
|
},
|
|
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";
|
|
}
|
|
}
|