Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorStatusBatchTests.cs
T
Joseph Doherty 52708bff52 fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list
writes (its element types are only Boolean/Int32/Float/Double/String/
DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a
Double list to a Float array. EvaluateEquipmentWriteStructure now converts
a numeric array of another numeric element type element-wise, CHECKED, into
the node's element type and hands the converted array back through
OnEquipmentTagWrite's ref value, so the node stores and the driver receives
the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to-
integer or Float-overflowing element refuses the whole write BadTypeMismatch
(nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays
closed. Non-numeric mismatches stay refused. The wrong 'no client relies on
a looser rule' comment is corrected.

MINOR-1: the array-value-to-scalar-node refusal broke writes back to
browse-committed array tags (committed scalar, isArray never set) on
pass-through drivers. It now applies only when the owning driver's scalar
encoder cannot take an array (DriverArrayWriteSupport.
RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan.
ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue
-> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node
manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the
classifier whitelist (driver-type edit rebuilds); a mirror whose flag
changes is recreated, not updated in place.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:31:46 -04:00

198 lines
9.7 KiB
C#

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;
/// <summary>
/// <see cref="OpcUaPublishActor.AttributeStatusBatchUpdate"/> — the status-only route to the sink
/// that the comms-loss fan-out uses (archreview 03/S15). It must reach
/// <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> as exactly one call, never decompose into
/// value writes (which would null every node's value), and survive a throwing sink.
/// </summary>
public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
{
/// <summary>
/// One message, one <c>WriteStatuses</c> call carrying every item verbatim — and <b>zero</b> value
/// writes. The second half is load-bearing: a handler that fell through to <c>WriteValues</c> (or
/// to the interface's default, which loops <c>WriteValue</c> with a null value) would satisfy "the
/// status arrived" while erasing the last known value the annotation exists to keep.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Partial_failure_still_counts_the_items_that_landed()
{
var sink = new PartialFailureSink();
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(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");
}
/// <summary>
/// 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.
/// </summary>
[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<InvalidOperationException>().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;
}
/// <summary>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.</summary>
private class RecordingSink : IOpcUaAddressSpaceSink
{
private readonly object _gate = new();
private readonly List<IReadOnlyList<StatusWrite>> _statusBatches = new();
private readonly List<IReadOnlyList<ValueWrite>> _valueBatches = new();
private readonly List<ValueWrite> _singles = new();
public IReadOnlyList<IReadOnlyList<StatusWrite>> StatusBatches { get { lock (_gate) return _statusBatches.ToArray(); } }
public IReadOnlyList<IReadOnlyList<ValueWrite>> ValueBatches { get { lock (_gate) return _valueBatches.ToArray(); } }
public IReadOnlyList<ValueWrite> 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<ValueWrite> values)
{
lock (_gate) _valueBatches.Add(values.ToArray());
}
// Overrides the interface's default (which loops WriteValue with a NULL value).
public virtual void WriteStatuses(IReadOnlyList<StatusWrite> 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<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>Throws a PLAIN exception out of the status batch — drives the handler's general catch.</summary>
private sealed class ThrowOnWriteStatusesSink : RecordingSink
{
private int _threw;
public bool Threw => Volatile.Read(ref _threw) != 0;
public override void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
{
Interlocked.Exchange(ref _threw, 1);
throw new InvalidOperationException("simulated status-write fault");
}
}
/// <summary>Models the real sink's PARTIAL failure: applies every item with a non-empty NodeId, then
/// reports the aggregate as a <see cref="ValueWriteBatchException"/>.</summary>
private sealed class PartialFailureSink : RecordingSink
{
private int _threw;
public bool Threw => Volatile.Read(ref _threw) != 0;
public override void WriteStatuses(IReadOnlyList<StatusWrite> 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"));
}
}
}