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; /// /// Task 7 of the value-pipeline batching work: /// must reach the sink as exactly ONE call — that single /// call is the whole point (the SDK-backed sink takes its lock once per batch instead of once per value), /// so a handler that looped WriteValue would pass a "the values arrived" assertion while delivering /// none of the win. Hence the fake counts per-item WriteValue calls separately and the assertions /// require that count to be zero. /// The per-item receive stays — VirtualTagHostActor /// and the alarm paths still send it — so it is pinned here too. /// public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase { /// A 3-item batch lands as one WriteValues call carrying all three items verbatim (field-exact, /// realm included), and never decomposes into per-item WriteValue calls. [Fact] public void Batch_message_reaches_sink_as_one_WriteValues_call() { var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc); var items = new[] { new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw), new ValueWrite("AreaA/Line1/Eq1/Speed", 42, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns), new ValueWrite("Plant/Modbus/dev1/Temp", 7.5d, OpcUaStatus.BadNoCommunication, t0.AddSeconds(1), AddressSpaceRealm.Raw), }; actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(items)); AwaitAssert(() => sink.Batches.Count.ShouldBe(1), PresenceBudget); var batch = sink.Batches[0]; batch.Count.ShouldBe(3); batch[0].ShouldBe(items[0]); batch[1].ShouldBe(items[1]); batch[2].ShouldBe(items[2]); // The load-bearing half: one batched call, NOT three per-item calls. sink.SingleWrites.Count.ShouldBe(0); } /// A sink that throws out of WriteValues is swallowed and logged — the actor survives and keeps /// processing, rather than restarting and dropping traffic. /// The survival assertion alone does NOT prove the catch exists: Akka's default supervision /// RESTARTS a throwing actor and the restart preserves the mailbox, so a follow-up message lands either /// way. The load-bearing assertions are therefore the two event filters — the exception must never reach /// supervision (no Error carrying it is ever logged), and the handler's own Warning must be logged so the /// fault is swallowed but not silent. Deleting the catch turns BOTH red. [Fact] public void Sink_throw_is_swallowed_and_logged() { var sink = new ThrowOnWriteValuesSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc); // Expect(0, …) counts matches at the END of the action, so everything that must have happened by // then goes INSIDE it — otherwise the absence assertion races the actor and passes vacuously. EventFilter.Exception().Expect(0, () => { // The handler caught it AND said so: its own Warning, not supervision's Error. EventFilter.Warning(contains: "sink.WriteValues threw").ExpectOne(() => actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[] { new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw), }))); // Survival: a follow-up on the untouched per-item path lands only if the actor is still 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(); } /// A PARTIAL failure is metered honestly: a sink that applies 2 of 3 items and reports the /// aggregate as a must advance the write tally by the SUCCEEDED /// count, not by zero (the general catch's behaviour) and not by the whole batch. The invariant the /// batching work must not break is that the tally counts per ITEM — so a batch that half-failed reports /// exactly the half that landed. [Fact] public void Partial_failure_still_counts_the_items_that_landed() { var sink = new PartialFailureSink(); // TestActorRef so the actor's own WriteCount tally is readable — the counter the handler advances is // the thing under test, and nothing else observes it. var actor = ActorOfAsTestActorRef(OpcUaPublishActor.PropsForTests(sink: sink)); var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc); actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[] { new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw), new ValueWrite("", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw), new ValueWrite("Plant/Modbus/dev1/Temp", 3, OpcUaStatus.Good, 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"); // The actor survived the partial failure and keeps pumping. actor.Tell(new OpcUaPublishActor.AttributeValueUpdate( "Plant/Modbus/dev1/Other", 4, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw)); sink.SingleWrites.Count.ShouldBe(1); actor.UnderlyingActor.WriteCount.ShouldBe(3); } /// The pre-existing single-item AttributeValueUpdate path is untouched by the batch addition. [Fact] public void Single_item_message_still_works() { var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc); actor.Tell(new OpcUaPublishActor.AttributeValueUpdate( "AreaA/Line1/Eq1/Speed", 99, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns)); AwaitAssert(() => sink.SingleWrites.Count.ShouldBe(1), PresenceBudget); sink.SingleWrites[0].ShouldBe(new ValueWrite("AreaA/Line1/Eq1/Speed", 99, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns)); sink.Batches.Count.ShouldBe(0); } 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 batched and per-item value writes separately, so a handler that looped WriteValue /// instead of calling WriteValues once is distinguishable from one that batched. private class RecordingSink : IOpcUaAddressSpaceSink { private readonly object _gate = new(); private readonly List> _batches = new(); private readonly List _singles = new(); public IReadOnlyList> Batches { get { lock (_gate) return _batches.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)); } // Overrides the interface's default WriteValues so the batch is observed as one call, not N. public virtual void WriteValues(IReadOnlyList values) { lock (_gate) _batches.Add(values.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") { } } /// A sink whose batched write throws a PLAIN exception — drives the handler's GENERAL catch (an /// unrecognised sink failure, where nothing is known about what landed, so nothing is counted). private sealed class ThrowOnWriteValuesSink : RecordingSink { private int _threw; public bool Threw => Volatile.Read(ref _threw) != 0; public override void WriteValues(IReadOnlyList values) { Interlocked.Exchange(ref _threw, 1); throw new InvalidOperationException("simulated batched-write fault"); } } /// A sink modelling the real SDK-backed one's PARTIAL failure: it applies every item with a /// non-empty NodeId, then reports the aggregate as a . Drives the /// handler's typed catch. private sealed class PartialFailureSink : RecordingSink { private int _threw; public bool Threw => Volatile.Read(ref _threw) != 0; public override void WriteValues(IReadOnlyList values) { var applied = values.Where(v => !string.IsNullOrEmpty(v.NodeId)).ToArray(); var failedCount = values.Count - applied.Length; if (failedCount == 0) { base.WriteValues(values); return; } base.WriteValues(applied); Interlocked.Exchange(ref _threw, 1); throw new ValueWriteBatchException( firstFailedNodeId: values.First(v => string.IsNullOrEmpty(v.NodeId)).NodeId, failedCount: failedCount, succeededCount: applied.Length, firstFailure: new ArgumentException("empty NodeId")); } } }