docs(client): document Galaxy fallback operator-comment limitation + pin null behavior

Closes backlog #12: Galaxy's AckMsg attribute is WRITE-ONLY and the OPC UA
event SelectClause carries no comment field, making OperatorComment
unrecoverable on the sub-attribute fallback path. Documents the two concrete
reasons in-code and tightens the AlarmEventArgs XML doc; adds pinning test
OnAlarmEvent_GalaxyFallback_LeavesOperatorCommentNull.
This commit is contained in:
Joseph Doherty
2026-06-18 13:06:37 -04:00
parent 6c6a2c4203
commit fac4418708
3 changed files with 92 additions and 3 deletions
@@ -1251,6 +1251,82 @@ public class OpcUaClientServiceTests : IDisposable
received.Message.ShouldBe("Fallback message"); // from DescAttrName read
}
/// <summary>
/// Pins the accepted limitation that OperatorComment is always null on the Galaxy
/// sub-attribute fallback path. Two concrete reasons make it unrecoverable:
/// (a) Galaxy's AckMsg attribute is WRITE-ONLY — no readable attribute exposes the
/// last operator comment — and (b) the OPC UA event SelectClause carries no comment
/// field. The operator comment is only available via the gateway's native alarm feed
/// (GalaxyAlarmTransition.OperatorComment in the Galaxy driver path, not here).
/// </summary>
[Fact]
public async Task OnAlarmEvent_GalaxyFallback_LeavesOperatorCommentNull()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter
{
NextSubscription = fakeSub,
ReadResponseFunc = nodeId =>
{
var key = nodeId.ToString();
if (key.EndsWith(".InAlarm"))
return new DataValue(new Variant(true), StatusCodes.Good);
if (key.EndsWith(".Acked"))
return new DataValue(new Variant(true), StatusCodes.Good);
if (key.EndsWith(".TimeAlarmOn"))
return new DataValue(new Variant(new DateTime(2026, 1, 1, 12, 0, 0)), StatusCodes.Good);
if (key.EndsWith(".DescAttrName"))
return new DataValue(new Variant("Ack message"), StatusCodes.Good);
return new DataValue(StatusCodes.BadNodeIdUnknown);
}
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
AlarmEventArgs? received = null;
var raised = new TaskCompletionSource();
_service.AlarmEvent += (_, e) =>
{
received = e;
raised.TrySetResult();
};
await _service.SubscribeAlarmsAsync();
var handle = fakeSub.ActiveHandles.First();
// AckedState/Id (8) and ActiveState/Id (9) are null, triggering the Galaxy
// sub-attribute fallback; SourceNode (12) is set so conditionNodeId is populated.
var fields = new EventFieldList
{
EventFields =
[
new Variant(new byte[] { 4, 5, 6 }), // 0: EventId
new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
new Variant("Source2"), // 2: SourceName
new Variant(DateTime.MinValue), // 3: Time
new Variant(new LocalizedText("Ack")), // 4: Message
new Variant((ushort)200), // 5: Severity
new Variant("CondName2"), // 6: ConditionName
new Variant(true), // 7: Retain
Variant.Null, // 8: AckedState/Id — missing
Variant.Null, // 9: ActiveState/Id — missing
new Variant(true), // 10: EnabledState/Id
new Variant(false), // 11: SuppressedOrShelved
new Variant("ns=2;s=ConditionId2") // 12: SourceNode
]
};
fakeSub.SimulateEvent(handle, fields);
// The fallback runs on a background Task.Run continuation — wait briefly for it.
await Task.WhenAny(raised.Task, Task.Delay(500));
received.ShouldNotBeNull();
// OperatorComment must be null: AckMsg is WRITE-ONLY in Galaxy and the OPC UA
// event SelectClause carries no comment field — both reasons are unrecoverable
// on this client sub-attribute fallback path.
received!.OperatorComment.ShouldBeNull();
}
// --- Failover tests ---
/// <summary>