bd63810645
- Native-condition Acknowledge on a non-Primary node returns BadInvalidState (nothing routed/applied/reported) via NativeAlarmAckPrimaryGate, fed from IDriverPrimaryRoleSource by OtOpcUaServerHostedService. - A re-ack carrying a comment reports one comment event (Acked/Confirmed unchanged). - TimedShelve applies its requested duration and the scripted projection carries UnshelveAtUtc, so ShelvingState/UnshelveTime counts down instead of ~24.8 days. - Native re-ack still routed to the driver (deliberate: documented retry path), pinned. - AdminUI /alerts client ack/shelve path test; docs/AlarmTracking.md decisions incl. accepted SDK EventsMonitored residual (item 6). - Also #599/03/S20: RemoveEquipmentSubtree severs inverse Organizes edges. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
652 lines
35 KiB
C#
652 lines
35 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)]
|
|
// Native conditions support Acknowledge and AddComment only (the rest: A_refused_native_method_…).
|
|
[InlineData("Acknowledge", true, true)]
|
|
[InlineData("AddComment", 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", false, 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);
|
|
}
|
|
|
|
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, native, 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 native condition supports Acknowledge and AddComment only: Confirm, the shelve methods and
|
|
/// Disable are refused, report nothing, and leave the condition Confirmed and Unshelved.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Native Confirm/Shelve used to be accepted and applied locally — per node, never upstream, never to the
|
|
/// redundant peer, and undone by the driver projection (a one-shot shelve silently, a timed one never
|
|
/// expiring). Unshelve on an unshelved condition is refused by the SDK itself before our veto runs.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData("Confirm")]
|
|
[InlineData("OneShotShelve")]
|
|
[InlineData("TimedShelve")]
|
|
[InlineData("Unshelve")]
|
|
[InlineData("Disable")]
|
|
public async Task A_refused_native_method_reports_no_condition_event(string method)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync($"CondMethodRefused{method}", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseNative();
|
|
var current = PreState(method, 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), method, events.LatestEventId(serverItem, "base"), ct));
|
|
if (method != "Unshelve") 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);
|
|
var sentinel = events.ConditionEvents(serverItem, "sentinel")[0];
|
|
sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
|
sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
|
|
|
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(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// An OPC UA Acknowledge on a native condition reports exactly one event with <c>ConfirmedState</c> still
|
|
/// true, and the condition stays Confirmed through the driver's echo, a clear and a re-activation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The SDK's Acknowledge sets Confirmed false on any condition that supports Confirm, and nothing resets
|
|
/// it for a native alarm — ScadaBridge maps <c>ConfirmedState</c> into its alarm state, so every native
|
|
/// alarm would read Unconfirmed after its first HMI ack (review of <c>167a48bf</c>).
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_native_acknowledge_keeps_the_condition_confirmed_through_clear_and_reactivation()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("CondNativeAckConfirmed", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseNative();
|
|
rig.Nm.NativeAlarmAckRouter = _ => { };
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Project(true, NativeState(active: true, acked: false, "base"));
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
|
|
|
await CallAsync(session, ConditionNodeId(session, true), "Acknowledge", events.LatestEventId(item, "base"), ct);
|
|
rig.Project(true, NativeState(active: true, acked: true, "base")); // the driver's echo of the ack
|
|
rig.Project(true, NativeState(active: false, acked: true, "cleared"));
|
|
rig.Project(true, NativeState(active: true, acked: false, "reactivated"));
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "reactivated"));
|
|
|
|
var baseEvents = events.ConditionEvents(item, "base");
|
|
baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta");
|
|
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
|
foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated")))
|
|
e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
|
|
/// reports exactly one unshelve event after the one shelve event.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The expiry is the engine's (computed from the command's <c>UnshelveAtUtc</c>). Since Gitea #597 the node
|
|
/// is shelved for the real duration, so the SDK's own timer fires at the same instant — but it only routes
|
|
/// an Unshelve back to the engine (ignored by this stand-in, a no-op on an unshelved engine alarm) and
|
|
/// changes no node state, so it adds no event. The stand-in engine here schedules its expiry from the real
|
|
/// <c>UnshelveAtUtc</c> the method handler computed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("CondTimedExpiry", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseScripted();
|
|
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
|
Task? expiry = null;
|
|
rig.Nm.AlarmCommandRouter = cmd =>
|
|
{
|
|
if (cmd.Operation != "TimedShelve") return;
|
|
current = current with { Shelving = AlarmShelvingKind.Timed };
|
|
rig.ProjectLater(native: false, current);
|
|
var unshelveAt = cmd.UnshelveAtUtc!.Value;
|
|
expiry = Task.Run(async () =>
|
|
{
|
|
var wait = unshelveAt - DateTime.UtcNow;
|
|
if (wait > TimeSpan.Zero) await Task.Delay(wait);
|
|
current = current with { Shelving = AlarmShelvingKind.Unshelved };
|
|
rig.Project(native: false, current);
|
|
});
|
|
};
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Project(false, current);
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
|
|
|
await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 1000);
|
|
await rig.DrainProjectionsAsync();
|
|
(expiry is not null).ShouldBeTrue("the TimedShelve reached the engine");
|
|
await expiry!;
|
|
|
|
rig.Project(false, current = current with { Message = "sentinel" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
|
|
|
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
|
shelving.ShouldBe(
|
|
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
|
ObjectIds.ShelvedStateMachineType_Unshelved],
|
|
"base, then exactly one shelve event, then exactly one expiry event");
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A scripted condition's events carry the real remaining shelve time in
|
|
/// <c>ShelvingState/UnshelveTime</c> — after a client TimedShelve, after the engine's re-projection of it,
|
|
/// and after an engine-driven timed shelve (the AdminUI <c>/alerts</c> path, which never calls the method).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Gitea #597: both paths applied the shelve with <c>shelvingTime 0</c>, which the SDK reads as "the
|
|
/// longest shelve allowed" — <c>MaxTimeShelved</c>, <c>int.MaxValue</c> ms — so every timed shelve
|
|
/// advertised ~24.8 days remaining while the engine expired it on time. Read through the event select
|
|
/// clause rather than a Read of the child node: the condition's children carry their type's
|
|
/// instance-declaration NodeIds (<c>i=9189</c> for UnshelveTime), so a direct Read reaches the type, not
|
|
/// this condition.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_scripted_timed_shelve_reports_the_real_remaining_unshelve_time()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("CondUnshelveTime", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseScripted();
|
|
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
|
rig.Nm.AlarmCommandRouter = cmd =>
|
|
{
|
|
if (cmd.Operation != "TimedShelve") return;
|
|
// The engine's answer carries its own expiry — what ScriptedAlarmHostActor projects.
|
|
current = current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = cmd.UnshelveAtUtc };
|
|
rig.ProjectLater(native: false, current);
|
|
};
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
double RemainingMs(string message) =>
|
|
(double)events.ConditionEvents(item, message)[^1][RecordedEvents.UnshelveTimeField].Value;
|
|
|
|
rig.Project(false, current);
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
|
RemainingMs("base").ShouldBe(0d, "unshelved");
|
|
|
|
// The method's one event carries the shelve's remaining time …
|
|
await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 60_000);
|
|
await rig.DrainProjectionsAsync();
|
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
|
|
RemainingMs("base").ShouldBeInRange(50_000, 60_000, "the TimedShelve method event");
|
|
|
|
// … and so does a later transition, after the engine's re-projection re-applied the shelve.
|
|
rig.Project(false, current = current with { Message = "after-reprojection" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "after-reprojection"));
|
|
RemainingMs("after-reprojection").ShouldBeInRange(50_000, 60_000, "after the engine's re-projection");
|
|
|
|
// Engine-driven (the AdminUI path): unshelve, then a timed shelve that reaches the node only as a
|
|
// projection, never through the method.
|
|
rig.Project(false, current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null, Message = "unshelved" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "unshelved"));
|
|
RemainingMs("unshelved").ShouldBe(0d);
|
|
rig.Project(false, current with
|
|
{
|
|
Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = DateTime.UtcNow.AddSeconds(30), Message = "projected",
|
|
});
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "projected"));
|
|
RemainingMs("projected").ShouldBeInRange(20_000, 30_000, "a projected timed shelve");
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// On a node that is not the redundancy Primary, an OPC UA Acknowledge of a NATIVE condition is refused
|
|
/// with <c>BadInvalidState</c>: nothing is routed to the driver, nothing is applied, nothing is reported.
|
|
/// On the Primary the same call is accepted (the positive control).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Gitea #597 decision. The ack used to be applied locally and reported, while <c>DriverHostActor</c>'s
|
|
/// Primary gate dropped the route upstream — so the driver never acknowledged, its next transition
|
|
/// projected <c>Acked = false</c>, and a client acking against the Secondary saw acked → unacked. An
|
|
/// honest refusal replaces an ack that visibly sticks and then reverts. Scripted conditions are
|
|
/// unaffected: their engine runs on both nodes, so the Secondary's ack is real.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_native_acknowledge_on_a_non_primary_node_is_refused()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("CondNativeAckSecondary", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseNative();
|
|
var current = PreState("Acknowledge", native: true) with { Message = "base" };
|
|
WireSimulatedOwners(rig, native: true, () => current, s => current = s);
|
|
var routed = 0;
|
|
var driverAck = rig.Nm.NativeAlarmAckRouter!;
|
|
rig.Nm.NativeAlarmAckRouter = ack => { Interlocked.Increment(ref routed); driverAck(ack); };
|
|
var primary = false;
|
|
rig.Nm.NativeAlarmAckPrimaryGate = () => primary;
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
rig.Project(native: true, current);
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
|
var condition = ConditionNodeId(session, native: true);
|
|
|
|
var refused = await Should.ThrowAsync<ServiceResultException>(
|
|
CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct));
|
|
refused.StatusCode.ShouldBe(StatusCodes.BadInvalidState);
|
|
routed.ShouldBe(0, "a refused ack reaches no driver");
|
|
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value
|
|
.ShouldBeFalse("a refused ack applies nothing");
|
|
|
|
// Positive control: the same call on the Primary is accepted, routed and reported.
|
|
primary = true;
|
|
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
|
await rig.DrainProjectionsAsync();
|
|
routed.ShouldBe(1);
|
|
|
|
rig.Project(native: true, current = current with { Message = "sentinel" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
|
var baseEvents = events.ConditionEvents(item, "base");
|
|
baseEvents.Count.ShouldBe(2, "the base transition plus the Primary's one ack event; the refusal reported nothing");
|
|
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Acknowledging an acknowledged condition WITHOUT a comment reports nothing, and does not flip a scripted
|
|
/// condition's Confirmed out of step with its engine. With a comment it reports exactly one event carrying
|
|
/// that comment — the comment is a Part 9 change in its own right, as AddComment is (Gitea #597) — and
|
|
/// still leaves Acked/Confirmed alone.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The SDK accepts a re-ack, writes its comment, and sets Confirmed false again; the engine ignores it.
|
|
/// The scripted leg acknowledges and confirms first, so the flip would be visible on the sentinel. Before
|
|
/// #597 a commented re-ack wrote the comment to the node but reported nothing, so no subscriber ever saw
|
|
/// it.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(false, null)]
|
|
[InlineData(true, null)]
|
|
[InlineData(false, "again")]
|
|
[InlineData(true, "again")]
|
|
public async Task A_re_acknowledge_reports_only_its_comment_and_changes_nothing_else(bool native, string? reAckComment)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync($"CondReAck{native}", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
|
rig.MaterialiseNative();
|
|
rig.MaterialiseScripted();
|
|
var current = PreState("Acknowledge", native) with { Message = "base" };
|
|
WireSimulatedOwners(rig, native, () => current, s => current = s);
|
|
var routedAcks = 0;
|
|
var driverAck = rig.Nm.NativeAlarmAckRouter!;
|
|
rig.Nm.NativeAlarmAckRouter = ack => { Interlocked.Increment(ref routedAcks); driverAck(ack); };
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
|
|
var condition = ConditionNodeId(session, native);
|
|
rig.Project(native, current);
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
|
|
|
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
|
await rig.DrainProjectionsAsync();
|
|
var expected = 2;
|
|
if (!native)
|
|
{
|
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
|
|
await CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
|
|
await rig.DrainProjectionsAsync();
|
|
expected = 3;
|
|
}
|
|
|
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == expected);
|
|
var routedBefore = routedAcks;
|
|
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: reAckComment);
|
|
await rig.DrainProjectionsAsync();
|
|
|
|
rig.Project(native, current = current with { Message = "sentinel" });
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
|
|
|
var baseEvents = events.ConditionEvents(item, "base");
|
|
if (reAckComment is null)
|
|
{
|
|
baseEvents.Count.ShouldBe(expected, "an uncommented re-ack must report nothing");
|
|
}
|
|
else
|
|
{
|
|
baseEvents.Count.ShouldBe(expected + 1, "a commented re-ack reports exactly one comment event");
|
|
var commented = baseEvents[^1];
|
|
(commented[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe(reAckComment);
|
|
commented[RecordedEvents.AckedField].Value.ShouldBe(true);
|
|
commented[RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the commented re-ack must not unconfirm the node");
|
|
}
|
|
|
|
// A native re-ack is still routed to the driver: the route is fire-and-forget, so a node showing
|
|
// Acked does not prove the upstream system acknowledged, and re-acking is the documented retry
|
|
// (docs/AlarmTracking.md, "Best-effort during reconnect"). Pinned so it is changed deliberately.
|
|
if (native) (routedAcks - routedBefore).ShouldBe(1);
|
|
|
|
var sentinel = events.ConditionEvents(item, "sentinel");
|
|
sentinel.Count.ShouldBe(1);
|
|
sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true);
|
|
sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node");
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static AlarmConditionSnapshot NativeState(bool active, bool acked, string message) => new(
|
|
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message);
|
|
|
|
/// <summary>The condition state a case starts from — one in which its method is accepted.</summary>
|
|
internal 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>
|
|
internal 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, bool native, VariantCollection e)
|
|
{
|
|
// A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so).
|
|
if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
|
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, double shelvingTimeMs = 60_000,
|
|
string? ackComment = "ack")
|
|
{
|
|
var (methodId, args) = method switch
|
|
{
|
|
"Acknowledge" => (MethodIds.AcknowledgeableConditionType_Acknowledge,
|
|
new object[] { eventId!, ackComment is null ? LocalizedText.Null : new LocalizedText(ackComment) }),
|
|
"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, [shelvingTimeMs]),
|
|
"Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null),
|
|
};
|
|
await session.CallAsync(condition, methodId, ct, args);
|
|
}
|
|
}
|