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;
///
/// 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 (i=2253) item as exactly one condition event
/// carrying the new state.
///
///
///
/// Before #587 a client Acknowledge produced no condition event at all: the SDK's own report is gated
/// on AutoReportStateChanges (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).
///
///
/// 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 sentinel transition arrives on
/// the same item, so a late duplicate would already be in the queue ahead of it.
///
///
public sealed class ConditionMethodEventTests
{
private const NodePermissions All =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
| NodePermissions.MethodCall;
///
/// 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.
///
[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(); }
}
///
/// A refused call reports nothing: Disable on a native condition is BadNotSupported, so no
/// transition happened and no condition event may claim one.
///
[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(
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(); }
}
///
/// 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).
///
[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 ─────────────────────────────
/// The condition state a case starts from — one in which its method is accepted.
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,
};
}
///
/// 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.
///
private static void WireSimulatedOwners(
ServerEventRig rig, bool native, Func current, Action 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);
}
}
///
/// 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.
///
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);
}
}