167a48bffa
#587 — a client Acknowledge/Confirm/AddComment/Shelve/Enable/Disable emitted no condition event: the SDK's ReportStateChange is gated on AutoReportStateChanges (never set), and the engine re-projection found the node already in the new state and was suppressed by the delta-gate. WireMethodTransitionReporting now wraps each method's SDK handler and, on Good, reports exactly once via ReportConditionEvent, under Lock so the routed re-projection cannot race in ahead of the SDK applying the state. Independent of the SDK's imprecise AreEventsMonitored bookkeeping. The shelve methods' state is applied by the wrapper (the SDK never applies it). Native conditions' Confirmed/Shelving are method-owned (NativeAlarmProjector's constants would re-report every ack and undo every shelve). MaxTimeShelved set to int.MaxValue ms at materialise — Create() left it 0 and every TimedShelve failed BadShelvingTimeOutOfRange. Wrong "SDK auto-fires (E2)" comments fixed. #588 — with ACL on, GeneralModelChange and AuditWriteUpdate events no longer go to Server.ReportEvent (ungated to every i=2253 item): ReportToServerObject delivers them per item, a model change narrowed to the Affected nodes the session may Browse (dropped when none), an audit write only with Read on its source plus the SDK audit/encryption guard. Flag off: unchanged. #586 review: materialise raises only the new condition's monitored unit (was a full O(N) reconcile per condition ⇒ O(N²) deploys under Lock). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
307 lines
16 KiB
C#
307 lines
16 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #587 over the wire — every accepted Part 9 method call (Acknowledge, Confirm, AddComment,
|
|
/// OneShotShelve, TimedShelve, Unshelve, Enable, Disable) on a native or scripted condition reaches a
|
|
/// folder event item and a Server-object (<c>i=2253</c>) item as <b>exactly one</b> condition event
|
|
/// carrying the new state.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Before #587 a client Acknowledge produced no condition event at all: the SDK's own report is gated
|
|
/// on <c>AutoReportStateChanges</c> (never set here), and the engine's re-projection found the node
|
|
/// already acknowledged and suppressed its own. The code comments claiming "the SDK auto-fires its own
|
|
/// event" were wrong — and a fix that only enabled the SDK's report would still double-report a shelve
|
|
/// (the SDK reports before any shelving state is applied, then the re-projection reports the change).
|
|
/// </para>
|
|
/// <para>
|
|
/// Each case runs the engine's (or, for a native alarm, the driver's) answer to the routed command as
|
|
/// a re-projection from a pool thread, so "exactly one" covers the duplicate the re-projection could
|
|
/// add, not only the method path. The count is read after a later <b>sentinel</b> transition arrives on
|
|
/// the same item, so a late duplicate would already be in the queue ahead of it.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ConditionMethodEventTests
|
|
{
|
|
private const NodePermissions All =
|
|
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
|
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
|
|
| NodePermissions.MethodCall;
|
|
|
|
/// <summary>
|
|
/// One accepted method call ⇒ one condition event, to the folder item and to the Server-object item,
|
|
/// with the new state — while a session holding no grant receives none of it through the Server object.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("Acknowledge", false, true)]
|
|
[InlineData("Confirm", false, true)]
|
|
[InlineData("AddComment", false, true)]
|
|
[InlineData("OneShotShelve", false, true)]
|
|
[InlineData("TimedShelve", false, true)]
|
|
[InlineData("Unshelve", false, true)]
|
|
[InlineData("Disable", false, true)]
|
|
[InlineData("Enable", false, true)]
|
|
[InlineData("Acknowledge", true, true)]
|
|
[InlineData("Confirm", true, true)]
|
|
[InlineData("AddComment", true, true)]
|
|
[InlineData("OneShotShelve", true, true)]
|
|
[InlineData("TimedShelve", true, true)]
|
|
[InlineData("Unshelve", true, true)]
|
|
// Flag off: the server-wide AlarmAck role admits the call; delivery is ungated.
|
|
[InlineData("Acknowledge", false, false)]
|
|
[InlineData("Acknowledge", true, false)]
|
|
[InlineData("OneShotShelve", true, false)]
|
|
public async Task An_accepted_method_call_reports_exactly_one_condition_event(string method, bool native, bool aclOn)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync($"CondMethod{method}{native}{aclOn}", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(aclOn, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseNative();
|
|
rig.MaterialiseScripted();
|
|
|
|
var current = PreState(method, native);
|
|
WireSimulatedOwners(rig, native, () => current, s => current = s);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var folderItem = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA));
|
|
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
folderItem.Status.Error.ShouldBeNull();
|
|
|
|
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
|
|
var ungrantedEvents = new RecordedEvents();
|
|
var ungrantedSubscription = await RecordedEvents.SubscribeAsync(ungranted, ungrantedEvents, ct);
|
|
var ungrantedItem = RecordedEvents.AddEventItem(ungrantedSubscription, ObjectIds.Server);
|
|
await ungrantedSubscription.ApplyChangesAsync(ct);
|
|
|
|
var condition = ConditionNodeId(session, native);
|
|
|
|
// Every transition the test drives also moves the simulated owner's state, so its re-projections
|
|
// carry the node's current message.
|
|
void ProjectAs(string message)
|
|
{
|
|
current = current with { Message = message };
|
|
rig.Project(native, current);
|
|
}
|
|
|
|
// A native condition's Confirmed and Shelving are reached only through the methods.
|
|
ProjectAs("setup");
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "setup"));
|
|
if (native && method == "Confirm")
|
|
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(folderItem, "setup"), ct);
|
|
if (native && method == "Unshelve")
|
|
await CallAsync(session, condition, "OneShotShelve", events.LatestEventId(folderItem, "setup"), ct);
|
|
await rig.DrainProjectionsAsync();
|
|
|
|
ProjectAs("base");
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base"));
|
|
|
|
await CallAsync(session, condition, method, events.LatestEventId(folderItem, "base"), ct);
|
|
await rig.DrainProjectionsAsync();
|
|
|
|
ProjectAs("sentinel");
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel")
|
|
&& (aclOn || ungrantedEvents.Has(ungrantedItem, "sentinel")));
|
|
|
|
foreach (var item in new[] { folderItem, serverItem })
|
|
{
|
|
var baseEvents = events.ConditionEvents(item, "base");
|
|
baseEvents.Count.ShouldBe(2, $"the base transition plus exactly one {method} event ({item.StartNodeId})");
|
|
AssertNewState(method, baseEvents[1]);
|
|
}
|
|
|
|
if (aclOn)
|
|
{
|
|
ungrantedEvents.Of(ungrantedItem).ShouldBeEmpty(
|
|
"a session with no grant receives no condition event through the Server object");
|
|
}
|
|
else
|
|
{
|
|
ungrantedEvents.ConditionEvents(ungrantedItem, "base").Count.ShouldBe(2, "flag off: ungated, still once");
|
|
}
|
|
|
|
await ungrantedSubscription.DeleteAsync(true, ct);
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A refused call reports nothing: Disable on a native condition is <c>BadNotSupported</c>, so no
|
|
/// transition happened and no condition event may claim one.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_refused_method_call_reports_no_condition_event()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("CondMethodRefused", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseNative();
|
|
var current = PreState("Disable", native: true);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var folderItem = RecordedEvents.AddEventItem(subscription, UnsNodeId(session, EqA));
|
|
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Project(native: true, current = current with { Message = "base" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "base"));
|
|
|
|
var refused = await Should.ThrowAsync<ServiceResultException>(
|
|
CallAsync(session, ConditionNodeId(session, native: true), "Disable", null, ct));
|
|
refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
|
|
|
rig.Project(native: true, current with { Message = "sentinel" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel"));
|
|
events.ConditionEvents(folderItem, "base").Count.ShouldBe(1);
|
|
events.ConditionEvents(serverItem, "base").Count.ShouldBe(1);
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Materialising N conditions while a Server-object item is live costs N monitored-bookkeeping visits,
|
|
/// not N²/2 — and every one of them still ends up marked monitored (#586 review, performance note).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Materialising_conditions_under_a_live_server_object_item_visits_each_once()
|
|
{
|
|
const int n = 40;
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("CondMonitoredCost", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.EnsureEquipment();
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
var before = rig.Nm.AllEventsMonitoringConditionVisits;
|
|
for (var i = 0; i < n; i++)
|
|
rig.Sink.MaterialiseAlarmCondition($"bulk-{i}", EqA, $"bulk {i}", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
|
|
|
(rig.Nm.AllEventsMonitoringConditionVisits - before).ShouldBe(n);
|
|
for (var i = 0; i < n; i++)
|
|
rig.Nm.ConditionEventsMonitored($"bulk-{i}", AddressSpaceRealm.Uns).ShouldBeTrue();
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
for (var i = 0; i < n; i++)
|
|
rig.Nm.ConditionEventsMonitored($"bulk-{i}", AddressSpaceRealm.Uns).ShouldBeFalse(
|
|
"the last item's delete still gives every unit back through the full reconcile");
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
/// <summary>The condition state a case starts from — one in which its method is accepted.</summary>
|
|
private static AlarmConditionSnapshot PreState(string method, bool native)
|
|
{
|
|
var s = new AlarmConditionSnapshot(
|
|
Active: true, Acknowledged: false, Confirmed: native, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "pre");
|
|
if (native) return s; // the driver projection's constants: Confirmed, Unshelved
|
|
|
|
return method switch
|
|
{
|
|
"Confirm" => s with { Acknowledged = true, Confirmed = false },
|
|
"Unshelve" => s with { Shelving = AlarmShelvingKind.OneShot },
|
|
"Enable" => s with { Enabled = false },
|
|
_ => s,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stands in for the scripted engine and the native driver: each routed command comes back as a
|
|
/// re-projection of the state it produces, from a pool thread.
|
|
/// </summary>
|
|
private static void WireSimulatedOwners(
|
|
ServerEventRig rig, bool native, Func<AlarmConditionSnapshot> current, Action<AlarmConditionSnapshot> set)
|
|
{
|
|
rig.Nm.NativeAlarmAckRouter = _ =>
|
|
{
|
|
// NativeAlarmProjector's answer to the driver's ack echo: Acked, with its Confirmed/Unshelved constants.
|
|
var next = current() with { Acknowledged = true, Confirmed = true, Shelving = AlarmShelvingKind.Unshelved };
|
|
set(next);
|
|
rig.ProjectLater(native: true, next);
|
|
};
|
|
rig.Nm.AlarmCommandRouter = cmd =>
|
|
{
|
|
if (native || cmd.AlarmId != ScriptedAlarmId) return; // the engine owns only scripted alarms
|
|
var s = current();
|
|
var next = cmd.Operation switch
|
|
{
|
|
"Acknowledge" => s with { Acknowledged = true },
|
|
"Confirm" => s with { Confirmed = true },
|
|
"OneShotShelve" => s with { Shelving = AlarmShelvingKind.OneShot },
|
|
"TimedShelve" => s with { Shelving = AlarmShelvingKind.Timed },
|
|
"Unshelve" => s with { Shelving = AlarmShelvingKind.Unshelved },
|
|
"Enable" => s with { Enabled = true },
|
|
"Disable" => s with { Enabled = false },
|
|
_ => s, // AddComment: no field of the snapshot changes
|
|
};
|
|
set(next);
|
|
rig.ProjectLater(native: false, next);
|
|
};
|
|
}
|
|
|
|
private static void AssertNewState(string method, VariantCollection e)
|
|
{
|
|
switch (method)
|
|
{
|
|
case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break;
|
|
case "Confirm": e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); break;
|
|
case "AddComment": (e[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe("note"); break;
|
|
case "OneShotShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); break;
|
|
case "TimedShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); break;
|
|
case "Unshelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); break;
|
|
case "Disable": e[RecordedEvents.EnabledField].Value.ShouldBe(false); break;
|
|
case "Enable": e[RecordedEvents.EnabledField].Value.ShouldBe(true); break;
|
|
default: throw new ArgumentOutOfRangeException(nameof(method), method, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the
|
|
/// condition's method declarations — the shelve methods included); throws on a bad result.
|
|
/// </summary>
|
|
private static async Task CallAsync(ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct)
|
|
{
|
|
var (methodId, args) = method switch
|
|
{
|
|
"Acknowledge" => (MethodIds.AcknowledgeableConditionType_Acknowledge, new object[] { eventId!, new LocalizedText("ack") }),
|
|
"Confirm" => (MethodIds.AcknowledgeableConditionType_Confirm, [eventId!, new LocalizedText("confirm")]),
|
|
"AddComment" => (MethodIds.ConditionType_AddComment, [eventId!, new LocalizedText("note")]),
|
|
"Enable" => (MethodIds.ConditionType_Enable, []),
|
|
"Disable" => (MethodIds.ConditionType_Disable, []),
|
|
"OneShotShelve" => (MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, []),
|
|
"TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [60_000.0]),
|
|
"Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null),
|
|
};
|
|
await session.CallAsync(condition, methodId, ct, args);
|
|
}
|
|
}
|