fix(runtime): the native-alarm projector learns a routed client acknowledgement (#641 review)

#641 keeps the projector's prior state across a deploy instead of resetting it to the
acked default. The projector never saw a client's ack (the SDK applies it to the node and
the host only routes it), so a Clear after ack+deploy now projected Acked=false and
un-acknowledged the operator's ack - previously masked by the per-deploy reset.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:19:24 -04:00
parent 5c88de078c
commit 25f02e4102
3 changed files with 44 additions and 0 deletions
@@ -473,6 +473,33 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
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,