using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; /// /// — the status-only route to the sink /// that the comms-loss fan-out uses (archreview 03/S15). It must reach /// as exactly one call, never decompose into /// value writes (which would null every node's value), and survive a throwing sink. /// public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase { /// /// One message, one WriteStatuses call carrying every item verbatim — and zero value /// writes. The second half is load-bearing: a handler that fell through to WriteValues (or /// to the interface's default, which loops WriteValue with a null value) would satisfy "the /// status arrived" while erasing the last known value the annotation exists to keep. /// [Fact] public void Status_batch_reaches_sink_as_one_WriteStatuses_call() { var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); var items = new[] { new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw), new StatusWrite("AreaA/Line1/Eq1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Uns), }; actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(items)); AwaitAssert(() => sink.StatusBatches.Count.ShouldBe(1), PresenceBudget); var batch = sink.StatusBatches[0]; batch.Count.ShouldBe(2); batch[0].ShouldBe(items[0]); batch[1].ShouldBe(items[1]); sink.ValueBatches.Count.ShouldBe(0, "a status annotation must never be routed as a value write"); sink.SingleWrites.Count.ShouldBe(0); } /// /// A partial failure is metered honestly, exactly as the value batch is: the tally advances by the /// items the sink DID apply, not by zero and not by the whole batch. /// [Fact] public void Partial_failure_still_counts_the_items_that_landed() { var sink = new PartialFailureSink(); var actor = ActorOfAsTestActorRef(OpcUaPublishActor.PropsForTests(sink: sink)); var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(new[] { new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw), new StatusWrite("", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw), new StatusWrite("Plant/Modbus/dev1/Temp", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw), })); sink.Threw.ShouldBeTrue(); actor.UnderlyingActor.WriteCount.ShouldBe(2, "the two items the sink DID apply must still be counted — not 0 (the general catch) and not 3"); } /// /// A throwing sink is swallowed and logged, so a node manager fault cannot restart the publish /// actor and drop live traffic. As in the value-batch suite, survival alone proves nothing (Akka's /// restart preserves the mailbox) — the load-bearing assertions are that no exception reaches /// supervision and that the handler logs its own Warning. /// [Fact] public void Sink_throw_is_swallowed_and_logged() { var sink = new ThrowOnWriteStatusesSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); EventFilter.Exception().Expect(0, () => { EventFilter.Warning(contains: "sink.WriteStatuses threw").ExpectOne(() => actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(new[] { new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw), }))); // Survival: a follow-up on the untouched per-item value path lands only if the actor is pumping. actor.Tell(new OpcUaPublishActor.AttributeValueUpdate( "Plant/Modbus/dev1/Temp", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw)); AwaitAssert(() => sink.SingleWrites.Count.ShouldBe(1), PresenceBudget); }); sink.Threw.ShouldBeTrue(); } private void AwaitAssert(Action assertion, TimeSpan duration) { var deadline = DateTime.UtcNow + duration; Exception? last = null; while (DateTime.UtcNow < deadline) { try { assertion(); return; } catch (Exception ex) { last = ex; Thread.Sleep(25); } } if (last is not null) throw last; } /// Records status batches, value batches and per-item value writes separately, so a handler /// that routed a status annotation down the value path is distinguishable from one that did not. private class RecordingSink : IOpcUaAddressSpaceSink { private readonly object _gate = new(); private readonly List> _statusBatches = new(); private readonly List> _valueBatches = new(); private readonly List _singles = new(); public IReadOnlyList> StatusBatches { get { lock (_gate) return _statusBatches.ToArray(); } } public IReadOnlyList> ValueBatches { get { lock (_gate) return _valueBatches.ToArray(); } } public IReadOnlyList SingleWrites { get { lock (_gate) return _singles.ToArray(); } } public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { lock (_gate) _singles.Add(new ValueWrite(nodeId, value, statusCode, sourceTimestampUtc, realm)); } public void WriteValues(IReadOnlyList values) { lock (_gate) _valueBatches.Add(values.ToArray()); } // Overrides the interface's default (which loops WriteValue with a NULL value). public virtual void WriteStatuses(IReadOnlyList statuses) { lock (_gate) _statusBatches.Add(statuses.ToArray()); } public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { } public void RebuildAddressSpace() { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } /// Throws a PLAIN exception out of the status batch — drives the handler's general catch. private sealed class ThrowOnWriteStatusesSink : RecordingSink { private int _threw; public bool Threw => Volatile.Read(ref _threw) != 0; public override void WriteStatuses(IReadOnlyList statuses) { Interlocked.Exchange(ref _threw, 1); throw new InvalidOperationException("simulated status-write fault"); } } /// Models the real sink's PARTIAL failure: applies every item with a non-empty NodeId, then /// reports the aggregate as a . private sealed class PartialFailureSink : RecordingSink { private int _threw; public bool Threw => Volatile.Read(ref _threw) != 0; public override void WriteStatuses(IReadOnlyList statuses) { var applied = statuses.Where(s => !string.IsNullOrEmpty(s.NodeId)).ToArray(); var failedCount = statuses.Count - applied.Length; if (failedCount == 0) { base.WriteStatuses(statuses); return; } base.WriteStatuses(applied); Interlocked.Exchange(ref _threw, 1); throw new ValueWriteBatchException( firstFailedNodeId: statuses.First(s => string.IsNullOrEmpty(s.NodeId)).NodeId, failedCount: failedCount, succeededCount: applied.Length, firstFailure: new ArgumentException("empty NodeId")); } } }