Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs
T
Joseph Doherty f1c188114b refactor(fleet): #542 delete the dead fleet-status chain (broadcaster, SignalR bridge, hub, messages)
#542 filed FleetStatusBroadcaster as blind to site nodes since the Phase 6 per-cluster mesh
split: it derives "the fleet" from central's own Akka gossip ring, which after the split is
just central's own pair. Scoping the cross-mesh rewrite found the deeper truth — the whole
chain is dead end to end, so this deletes it rather than reviving it.

  - Zero consumers. FleetStatusBroadcaster published FleetStatusChanged on the DPS
    "fleet-status" topic; FleetStatusSignalRBridge forwarded it to FleetStatusHub
    ("/hubs/fleet-status"); nothing anywhere connected. The AdminUI bans self-connecting
    HubConnections, no browser JS subscribes, and /fleet (Fleet.razor) reads
    NodeDeploymentState from the ConfigDb plus IClusterRoleInfo on a 5 s poll instead.
  - Its second input never existed. DriverHostStatusHeartbeat had no production sender —
    only the broadcaster's own test told it — so CurrentRevision was always null and the
    30 s staleness -> Degraded flip could never fire.
  - Post-Phase-6 DPS is pair-local, so even the central-only view it did produce was an
    accident of topology rather than a design.

The fleet visibility that actually works is unchanged and already cross-mesh-correct:
/fleet over NodeDeploymentState, and the four Phase 5 per-node telemetry dials.

Precedent for deleting inert machinery rather than activating it: #522 (driver stability
tiers), #507 (discovered-node injection), #525 (GetMemoryFootprint).

Deleted: FleetStatusBroadcaster (+ its nested DriverHostStatusHeartbeat), its admin
singleton registration and FleetStatusBroadcasterKey, FleetStatusSignalRBridge and its
key, FleetStatusHub and its route mapping, and the FleetStatusChanged / FleetNodeStatus /
FleetNodeHealth messages. Both Fleet/ directories survive — ControlPlane keeps
ClusterNodeAddressReconciler, Commons keeps GetDiagnostics. Docs swept where they claimed
a fleet-status channel exists (Telemetry.md's Phase 5 non-migrated channels,
Configuration.md, CLAUDE.md); completed-program plan docs under docs/plans and docs/v2 are
left as the historical record they are.

Build 0 errors, 868 warnings — byte-identical to master's rebuild baseline.
ControlPlane.Tests 231/231, AdminUI.Tests 1109/1109.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-06 17:21:30 -04:00

132 lines
5.4 KiB
C#

using Akka.Actor;
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hubs;
/// <summary>
/// Verifies the Phase 5 dark switch in <see cref="HubServiceCollectionExtensions.WithOtOpcUaSignalRBridges"/>:
/// <c>TelemetryDial:Mode = Dps</c> (default) spawns the four telemetry DPS bridge actors and NOT the
/// gRPC dial supervisor; <c>Grpc</c> spawns the dial supervisor and NONE of the four telemetry
/// bridges. The gate is proven on a real <see cref="ActorRegistry"/> from a started Akka host.
/// </summary>
public sealed class TelemetryModeWiringTests
{
/// <summary>Dps mode: the four telemetry bridges are registered; the dial supervisor is not.</summary>
[Fact]
public async Task Dps_mode_spawns_the_four_telemetry_bridges_and_not_the_dial_supervisor()
{
using var host = BuildBridgeHost(TelemetryDialOptions.ModeDps);
await host.StartAsync();
try
{
var registry = host.Services.GetRequiredService<ActorRegistry>();
registry.TryGet<AlertSignalRBridgeKey>(out _).ShouldBeTrue();
registry.TryGet<ScriptLogSignalRBridgeKey>(out _).ShouldBeTrue();
registry.TryGet<DriverStatusSignalRBridgeKey>(out _).ShouldBeTrue();
registry.TryGet<DriverResilienceStatusBridgeKey>(out _).ShouldBeTrue();
registry.TryGet<TelemetryDialSupervisorKey>(out _).ShouldBeFalse();
}
finally
{
await host.StopAsync();
}
}
/// <summary>Grpc mode: the dial supervisor is registered; none of the four telemetry bridges are.</summary>
[Fact]
public async Task Grpc_mode_spawns_the_dial_supervisor_and_none_of_the_four_telemetry_bridges()
{
using var host = BuildBridgeHost(TelemetryDialOptions.ModeGrpc);
await host.StartAsync();
try
{
var registry = host.Services.GetRequiredService<ActorRegistry>();
registry.TryGet<TelemetryDialSupervisorKey>(out _).ShouldBeTrue();
registry.TryGet<AlertSignalRBridgeKey>(out _).ShouldBeFalse();
registry.TryGet<ScriptLogSignalRBridgeKey>(out _).ShouldBeFalse();
registry.TryGet<DriverStatusSignalRBridgeKey>(out _).ShouldBeFalse();
registry.TryGet<DriverResilienceStatusBridgeKey>(out _).ShouldBeFalse();
}
finally
{
await host.StopAsync();
}
}
/// <summary>Unset (default) mode is treated as Dps — the four bridges spawn, the supervisor does not.</summary>
[Fact]
public async Task Default_mode_is_dps()
{
using var host = BuildBridgeHost(mode: null);
await host.StartAsync();
try
{
var registry = host.Services.GetRequiredService<ActorRegistry>();
registry.TryGet<AlertSignalRBridgeKey>(out _).ShouldBeTrue();
registry.TryGet<TelemetryDialSupervisorKey>(out _).ShouldBeFalse();
}
finally
{
await host.StopAsync();
}
}
/// <summary>Builds an admin-role host that runs <c>WithOtOpcUaSignalRBridges</c> under the given telemetry mode.</summary>
/// <param name="mode">The <c>TelemetryDial:Mode</c> value, or <see langword="null"/> to leave it at its default.</param>
private static IHost BuildBridgeHost(string? mode)
=> Host.CreateDefaultBuilder()
.ConfigureServices((_, services) =>
{
services.AddSignalR();
services.AddOtOpcUaDriverStatusServices();
services.AddSingleton<IDbContextFactory<OtOpcUaConfigDbContext>>(
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N")));
var options = new TelemetryDialOptions { ApiKey = "test-key" };
if (mode is not null)
{
options.Mode = mode;
}
services.AddSingleton<IOptions<TelemetryDialOptions>>(Options.Create(options));
services.AddAkka("otopcua-test", (ab, _) =>
{
ab.AddHocon(@"
akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
akka.remote.dot-netty.tcp.hostname = ""127.0.0.1""
akka.remote.dot-netty.tcp.port = 0
akka.cluster.seed-nodes = []
akka.cluster.roles = [""admin""]
", HoconAddMode.Prepend);
ab.WithOtOpcUaSignalRBridges();
});
})
.Build();
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose contexts share one InMemory database.</summary>
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName)
.Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}