feat(client): AddComment + comment CLI, drop Galaxy-era alarm fallback, serialize CLI tests (#598); Client.Shared off obsolete sync SDK APIs (#621)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1263,52 +1263,27 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled);
|
||||
}
|
||||
|
||||
// --- Alarm fallback path (Client.Shared-011) ---
|
||||
// --- Missing standard alarm fields (#598: the Galaxy-era supplemental reads are gone) ---
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Client.Shared-011: when standard AckedState/Id and ActiveState/Id
|
||||
/// fields are missing (null Value) but the SourceNode (ConditionId) field at index 12
|
||||
/// is populated, the client launches the Task.Run fallback that reads
|
||||
/// <c>InAlarm</c>/<c>Acked</c> from the condition node's Galaxy attributes. Verify
|
||||
/// the alarm event is delivered with the values from the supplemental reads.
|
||||
/// When the standard AckedState/Id and ActiveState/Id fields arrive null the event is delivered with
|
||||
/// Active=false / Acked=false and the client issues NO supplemental reads of
|
||||
/// <c><condition>.InAlarm</c>/<c>.Acked</c> (those attributes only existed on the retired Galaxy v2 server).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnAlarmEvent_MissingAckedActiveButHasConditionNode_FallbackReadsAndRaisesEvent()
|
||||
public async Task OnAlarmEvent_MissingAckedActive_DeliversDefaultsWithoutExtraReads()
|
||||
{
|
||||
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(false), 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("Fallback message"), StatusCodes.Good);
|
||||
return new DataValue(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
AlarmEventArgs? received = null;
|
||||
var raised = new TaskCompletionSource();
|
||||
_service.AlarmEvent += (_, e) =>
|
||||
{
|
||||
received = e;
|
||||
raised.TrySetResult();
|
||||
};
|
||||
_service.AlarmEvent += (_, e) => received = e;
|
||||
|
||||
await _service.SubscribeAlarmsAsync();
|
||||
|
||||
var handle = fakeSub.ActiveHandles.First();
|
||||
// AckedState/Id (8) and ActiveState/Id (9) are present but Variant.Value is null,
|
||||
// which triggers the supplemental Galaxy-attribute fallback; SourceNode (12) is set.
|
||||
var fields = new EventFieldList
|
||||
{
|
||||
EventFields =
|
||||
@@ -1321,8 +1296,8 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
new Variant((ushort)400), // 5: Severity
|
||||
new Variant("CondName"), // 6: ConditionName
|
||||
new Variant(true), // 7: Retain
|
||||
Variant.Null, // 8: AckedState/Id — missing
|
||||
Variant.Null, // 9: ActiveState/Id — missing
|
||||
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=ConditionId") // 12: SourceNode
|
||||
@@ -1330,90 +1305,14 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
};
|
||||
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));
|
||||
|
||||
// Delivery is now synchronous (no background fallback), so the event is already here.
|
||||
received.ShouldNotBeNull();
|
||||
received!.ActiveState.ShouldBeTrue(); // from InAlarm read
|
||||
received.AckedState.ShouldBeFalse(); // from Acked read
|
||||
received!.ActiveState.ShouldBeFalse();
|
||||
received.AckedState.ShouldBeFalse();
|
||||
received.ConditionNodeId.ShouldBe("ns=2;s=ConditionId");
|
||||
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();
|
||||
received.Message.ShouldBe("Initial");
|
||||
received.OperatorComment.ShouldBeNull();
|
||||
session.ReadCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// --- Failover tests ---
|
||||
|
||||
Reference in New Issue
Block a user