using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Gitea #662 — a native-alarm feed must survive a deploy that does NOT respawn its driver, end to end /// through the real and its real /// child. /// /// /// Found live on the docker-dev rig: a deploy that only disabled ANOTHER driver re-pushed the desired set to /// an unchanged OpcUaClient driver, whose data re-subscribe tore down the alarm event handler too. The /// condition then froze while upstream kept emitting, and only a respawn brought it back. Every existing /// end-to-end alarm test drives the driver directly (the host's driver is Enabled=false), so none of /// them could see the instance actor drop the feed. /// public sealed class NativeAlarmFeedSurvivesDeployTests : RuntimeActorTestBase { private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); private static readonly NodeId TestNode = NodeId.Parse("node-1:4053"); private const string DriverId = "DI-1"; private const string AlarmXPath = "Plant/Modbus/dev1/alarmX"; private const string AlarmConfig = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}"""; private static readonly (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] RawTags = [ (DriverId, "Modbus", "dev1", "speed", "Float", false), (DriverId, "Modbus", "dev1", "alarmX", "Float", false), ]; private static readonly Dictionary TagConfigs = new() { ["alarmX"] = AlarmConfig }; [Fact] public void An_upstream_alarm_still_reaches_its_condition_after_a_non_respawning_deploy() { var db = NewInMemoryDbFactory(); var factory = new RecordingSubscribableDriverFactory("Modbus"); var revA = V3ArtifactSeeder.SeedV3Deployment( db, RevA, RawTags, Array.Empty<(string, string, string, string, string?)>(), enabled: true, tagConfigByTag: TagConfigs); var (host, publish, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, revA, RevA, factory); RecordingSubscribableDriver driver = null!; AwaitAssert(() => { factory.Created.Count.ShouldBe(1); driver = factory.Created[0]; driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(1); driver.LastSubscribedAlarmRefs.ShouldBe(new[] { AlarmXPath }); driver.AlarmSubscriberCount.ShouldBe(1); }, duration: PresenceBudget); var dataSubscribesBefore = driver.SubscribeCount; // Revision B only ADDS another driver: DI-1's merged DriverConfig is byte-identical, so it is kept, // and the bulk push re-sends its (unchanged) desired set — which re-subscribes its data. var revB = V3ArtifactSeeder.SeedV3Deployment( db, RevB, RawTags, Array.Empty<(string, string, string, string, string?)>(), enabled: true, tagConfigByTag: TagConfigs, taglessDrivers: ["DI-2"]); host.Tell(new DispatchDeployment(revB, RevB, CorrelationId.NewId())); AwaitAssert(() => { driver.SubscribeCount.ShouldBeGreaterThan(dataSubscribesBefore, "precondition: the deploy must re-subscribe DI-1's data, or this test exercises nothing"); }, duration: PresenceBudget); driver.InitializeCount.ShouldBe(1, "precondition: DI-1 must NOT have been respawned or re-initialised"); driver.ShutdownCount.ShouldBe(0); factory.Created[0].ShouldBeSameAs(driver); driver.AlarmSubscriberCount.ShouldBe(1, "the alarm event handler must survive the data re-subscribe"); driver.FireAlarm(AlarmXPath, AlarmTransitionKind.Raise); var update = publish.FishForMessage( m => m.AlarmNodeId == AlarmXPath && m.State.Active, PresenceBudget); update.State.Acknowledged.ShouldBeFalse(); } }