Merge #597/#599: real UnshelveTime, commented re-ack reports, native ack refused off-Primary, /alerts ack-path test; surgically-added raw nodes announced, equipment-with-references removal keeps references severed; Backing* parity guard

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:12:59 -04:00
16 changed files with 873 additions and 89 deletions
@@ -0,0 +1,81 @@
using Akka.Hosting;
using Akka.TestKit.Xunit2;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Gitea #597 — the AdminUI <c>/alerts</c> acknowledge / shelve path, from the page's client call down to
/// the command the cluster receives: <see cref="AdminOperationsClient"/> (what <c>Alerts.razor</c>'s
/// per-row buttons call) Asks the real <see cref="AdminOperationsActor"/> registered under
/// <see cref="AdminOperationsActorKey"/>, which maps the request to an <see cref="AlarmCommand"/> and hands
/// it to the mesh transport. Until now only the actor half had a test; the client seam the page goes
/// through had none.
/// </summary>
/// <remarks>
/// The razor click handler itself is not driven here — this repo has no bUnit. What it adds on top of the
/// client call is the operator name and <c>comment: null</c>, both reproduced below exactly as the page
/// passes them.
/// </remarks>
public sealed class AlertsAckPathTests : TestKit
{
private (AdminOperationsClient Client, Akka.TestKit.TestProbe Mesh) Wire()
{
var mesh = CreateTestProbe("mesh-router");
var actor = Sys.ActorOf(AdminOperationsActor.Props(
AclScopeTestDb.Factory($"alerts-ack-{Guid.NewGuid():N}"),
coordinator: CreateTestProbe("coordinator").Ref,
probes: Enumerable.Empty<IDriverProbe>(),
meshRouter: mesh.Ref));
var registry = new ActorRegistry();
registry.Register<AdminOperationsActorKey>(actor);
return (new AdminOperationsClient(registry), mesh);
}
/// <summary>The page's Acknowledge reaches the cluster as an <c>Acknowledge</c> AlarmCommand on the
/// <c>alarm-commands</c> topic carrying the alarm id and the operator, and the page is told it was sent.</summary>
[Fact]
public async Task Acknowledge_from_the_alerts_page_dispatches_an_Acknowledge_command_and_reports_sent()
{
var (client, mesh) = Wire();
var result = await client.AcknowledgeAlarmAsync("alarm-42", "operator-jo", comment: null, CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Message.ShouldBeNull();
var sent = mesh.ExpectMsg<MeshCommand>(TimeSpan.FromSeconds(5));
sent.Topic.ShouldBe(AlarmCommandsTopic.Name);
var command = sent.Message.ShouldBeOfType<AlarmCommand>();
command.AlarmId.ShouldBe("alarm-42");
command.Operation.ShouldBe("Acknowledge");
command.User.ShouldBe("operator-jo");
command.Comment.ShouldBeNull();
command.UnshelveAtUtc.ShouldBeNull();
}
/// <summary>The page's timed shelve carries its expiry through to the command — the instant the engine
/// projects as the node's <c>UnshelveTime</c>.</summary>
[Fact]
public async Task Timed_shelve_from_the_alerts_page_carries_its_expiry_to_the_command()
{
var (client, mesh) = Wire();
var until = new DateTime(2026, 10, 8, 12, 30, 0, DateTimeKind.Utc);
var result = await client.ShelveAlarmAsync(
"alarm-7", "operator-kim", ShelveKind.Timed, unshelveAtUtc: until, comment: null, CancellationToken.None);
result.Ok.ShouldBeTrue();
var command = mesh.ExpectMsg<MeshCommand>(TimeSpan.FromSeconds(5)).Message.ShouldBeOfType<AlarmCommand>();
command.Operation.ShouldBe("TimedShelve");
command.UnshelveAtUtc.ShouldBe(until);
}
}