Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs
T
Joseph Doherty d33e4e20b5 feat(adminui): #546 wire DriverStatusPanel into /hosts driver rows (collapsed expander)
DriverStatusPanel was orphaned — zero references repo-wide — so the Restart and
Reconnect driver commands had NO reachable UI at all, despite working end to end
over the new gRPC mesh transport. Nothing failed to build, test or deploy while
that was true, which is why it went unnoticed.

The panel is now reachable per driver row on /hosts, behind a per-row "Controls"
expander that is collapsed by default: the table stays scannable, and only the
opened panels hold snapshot-store subscriptions. Expansion state is a HashSet
keyed by DriverInstanceId held on the page, so it survives the 5 s refresh that
replaces every row record. Both rows are @key'd by instance id so the panel —
which binds its DriverInstanceId once in OnInitializedAsync — can never be diffed
onto a different driver.

The panel's Enabled parameter is fed from real config rather than defaulted:
HostsDriverInstanceInfo/HostsDriverRow now carry DriverInstance.Enabled, which
LoadConfigAsync already had in hand. A snapshot with no matching configured
instance is treated as enabled — the row exists only because live health arrived,
so "Disabled — not deployed" would contradict the same row.

The expander is visible to every AuthenticatedRead viewer; the panel self-gates
only its Reconnect/Restart buttons on DriverOperator.

A source-scan pin (HostsPanelWiringTests) asserts /hosts still renders the panel
and still binds it to the row's own driver, cluster and enabled state — removing
the tag would silently restore the orphaned state.

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

342 lines
13 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts;
/// <summary>
/// Covers <see cref="HostsDriverView.Build"/>: the pure, DB-agnostic view-model builder that
/// groups driver-health snapshots by cluster, attaches each cluster's nodes, and enriches each
/// driver row with the configured name + type. The builder is null-safe and stable-ordered so
/// the (later) <c>/hosts</c> Razor section renders deterministically.
/// </summary>
public sealed class HostsDriverViewTests
{
private static readonly DateTime When = new(2026, 6, 18, 0, 0, 0, DateTimeKind.Utc);
private static DriverHealthChanged Snap(string cluster, string instance, string state = "Healthy") =>
new(cluster, instance, state, null, null, 0, When);
private static HostsNodeInfo Node(string cluster, string node, string host = "h", int port = 4840) =>
new(cluster, node, host, port);
private static HostsDriverInstanceInfo Instance(
string instance, string cluster, string name, string type, bool enabled = true) =>
new(instance, cluster, name, type, enabled);
[Fact]
public void Build_groups_two_clusters_with_their_nodes_and_snapshots()
{
var snaps = new[]
{
Snap("MAIN", "drv-a"),
Snap("EDGE", "drv-b"),
};
var nodes = new[]
{
Node("MAIN", "MAIN-1"),
Node("EDGE", "EDGE-1"),
};
var groups = HostsDriverView.Build(snaps, nodes, instances: null);
groups.Count.ShouldBe(2);
var main = groups.Single(g => g.ClusterId == "MAIN");
var edge = groups.Single(g => g.ClusterId == "EDGE");
main.Nodes.Select(n => n.NodeId).ShouldBe(new[] { "MAIN-1" });
main.Drivers.Select(d => d.DriverInstanceId).ShouldBe(new[] { "drv-a" });
edge.Nodes.Select(n => n.NodeId).ShouldBe(new[] { "EDGE-1" });
edge.Drivers.Select(d => d.DriverInstanceId).ShouldBe(new[] { "drv-b" });
}
[Fact]
public void Build_enriches_driver_row_from_matching_instance()
{
var groups = HostsDriverView.Build(
new[] { Snap("MAIN", "drv-a", "Faulted") },
nodes: null,
new[] { Instance("drv-a", "MAIN", "Pump House Modbus", "Modbus") });
var row = groups.Single().Drivers.Single();
row.DriverInstanceId.ShouldBe("drv-a");
row.Name.ShouldBe("Pump House Modbus");
row.DriverType.ShouldBe("Modbus");
row.State.ShouldBe("Faulted");
}
[Fact]
public void Build_leaves_name_and_type_null_for_unknown_driver()
{
var groups = HostsDriverView.Build(
new[] { Snap("MAIN", "orphan") },
nodes: null,
new[] { Instance("drv-a", "MAIN", "Known", "Modbus") });
var row = groups.Single().Drivers.Single(d => d.DriverInstanceId == "orphan");
row.Name.ShouldBeNull();
row.DriverType.ShouldBeNull();
}
[Fact]
public void Build_includes_cluster_with_nodes_but_no_snapshots()
{
var groups = HostsDriverView.Build(
snapshots: null,
new[] { Node("MAIN", "MAIN-1") },
instances: null);
var group = groups.Single();
group.ClusterId.ShouldBe("MAIN");
group.Nodes.Count.ShouldBe(1);
group.Drivers.ShouldBeEmpty();
}
[Fact]
public void Build_with_empty_inputs_returns_empty_list()
{
HostsDriverView.Build(
Array.Empty<DriverHealthChanged>(),
Array.Empty<HostsNodeInfo>(),
Array.Empty<HostsDriverInstanceInfo>()).ShouldBeEmpty();
}
[Fact]
public void Build_with_all_null_inputs_returns_empty_list_without_throwing()
{
HostsDriverView.Build(snapshots: null, nodes: null, instances: null).ShouldBeEmpty();
}
[Fact]
public void Build_orders_drivers_by_name_then_instance_id()
{
var snaps = new[]
{
Snap("MAIN", "drv-z"), // no instance -> null name -> sorts by id "drv-z"
Snap("MAIN", "drv-a"), // no instance -> null name -> sorts by id "drv-a"
Snap("MAIN", "drv-m"), // named "Alpha"
};
var instances = new[]
{
Instance("drv-m", "MAIN", "Alpha", "Modbus"),
};
var drivers = HostsDriverView.Build(snaps, nodes: null, instances).Single().Drivers;
// Sort key is (Name ?? DriverInstanceId): "Alpha"(drv-m), "drv-a", "drv-z".
drivers.Select(d => d.DriverInstanceId).ShouldBe(new[] { "drv-m", "drv-a", "drv-z" });
}
[Fact]
public void Build_takes_first_when_instance_id_appears_twice()
{
var groups = HostsDriverView.Build(
new[] { Snap("MAIN", "drv-a") },
nodes: null,
new[]
{
Instance("drv-a", "MAIN", "First", "Modbus"),
Instance("drv-a", "MAIN", "Second", "S7"),
});
var row = groups.Single().Drivers.Single();
row.Name.ShouldBe("First");
row.DriverType.ShouldBe("Modbus");
}
[Fact]
public void Build_orders_clusters_case_insensitively()
{
var groups = HostsDriverView.Build(
new[] { Snap("zeta", "d1"), Snap("Alpha", "d2") },
new[] { Node("Beta", "b1") },
instances: null);
groups.Select(g => g.ClusterId).ShouldBe(new[] { "Alpha", "Beta", "zeta" });
}
/// <summary>
/// Per-host connectivity flows through to the row (Gitea #521), and <c>DegradedHosts</c> picks out
/// exactly the hosts an operator needs to look at. This is the case the driver-level Status chip
/// cannot express: the driver is Healthy and one of its devices is not.
/// </summary>
[Fact]
public void Build_carries_host_statuses_and_flags_only_the_degraded_ones()
{
var snapshot = Snap("MAIN", "drv-a") with
{
HostStatuses =
[
new HostConnectivityStatus("plc-a", HostState.Running, When),
new HostConnectivityStatus("plc-b", HostState.Stopped, When),
new HostConnectivityStatus("plc-c", HostState.Faulted, When),
],
};
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
row.State.ShouldBe("Healthy");
row.HostStatuses!.Count.ShouldBe(3);
row.DegradedHosts.Select(h => h.HostName).ShouldBe(["plc-b", "plc-c"]);
}
/// <summary>
/// <see cref="HostState.Unknown"/> counts as degraded. A probe that has not completed its first tick
/// — or one a driver failed to start at all, which AbCip logs explicitly — reports Unknown, and
/// rendering that as healthy is how an unstarted probe stays invisible.
/// </summary>
[Fact]
public void Unknown_host_state_counts_as_degraded()
{
var snapshot = Snap("MAIN", "drv-a") with
{
HostStatuses = [new HostConnectivityStatus("plc-a", HostState.Unknown, When)],
};
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
row.DegradedHosts.ShouldHaveSingleItem().HostName.ShouldBe("plc-a");
}
/// <summary>
/// A driver with no probe keeps a null host list — distinct from a probe reporting zero hosts. The
/// /hosts column renders "—" for the former and "0 hosts" for the latter, and collapsing them would
/// claim every probe-less driver's devices are fine.
/// </summary>
[Fact]
public void A_driver_without_host_statuses_keeps_null_and_reports_no_degraded_hosts()
{
var row = HostsDriverView.Build([Snap("MAIN", "drv-a")], nodes: null, instances: null)
.Single().Drivers.Single();
row.HostStatuses.ShouldBeNull();
row.DegradedHosts.ShouldBeEmpty();
}
// ---- authored enabled-state (Gitea #546) ----
/// <summary>
/// The authored enabled flag reaches the row, which forwards it to the row's
/// <c>DriverStatusPanel</c>. Defaulting it to true would have the panel open a live
/// subscription for an instance that was deliberately taken out of the deployment.
/// </summary>
[Fact]
public void Build_carries_the_authored_enabled_flag_onto_the_row()
{
var groups = HostsDriverView.Build(
[Snap("MAIN", "drv-a")],
nodes: null,
[Instance("drv-a", "MAIN", "Pump House Modbus", "Modbus", enabled: false)]);
groups.Single().Drivers.Single().Enabled.ShouldBeFalse();
}
/// <summary>
/// A snapshot with no matching configured instance is treated as enabled. The row only exists
/// because live health arrived for it, so rendering the panel's "Disabled — not deployed"
/// notice would contradict the evidence on the same row.
/// </summary>
[Fact]
public void An_unmatched_driver_row_is_treated_as_enabled()
{
var row = HostsDriverView.Build([Snap("MAIN", "orphan")], nodes: null, instances: null)
.Single().Drivers.Single();
row.Enabled.ShouldBeTrue();
}
// ---- skipped tags (Gitea #541) ----
/// <summary>
/// The Makino case from Gitea #540, as the row renders it: every driver-level field reads healthy
/// while 20 of 22 tags are not being polled. The skip field is the only thing on the row that
/// contradicts it.
/// </summary>
[Fact]
public void A_healthy_row_still_reports_the_tags_its_driver_is_ignoring()
{
var snapshot = Snap("MAIN", "focas-z-34184") with
{
SkippedTags = new SkippedTagReport(22, 20,
[new SkippedTag("Makino/Identity/SeriesNumber", "invalid Address")]),
};
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
row.State.ShouldBe("Healthy");
row.ErrorCount5Min.ShouldBe(0);
row.LastError.ShouldBeNull();
row.HasSkippedTags.ShouldBeTrue("the row must contradict its own healthy-looking columns");
row.SkippedTags!.SkippedCount.ShouldBe(20);
}
/// <summary>
/// A truncated sample must SAY it is truncated. Without the note a capped list reads as the whole
/// list, and an operator who fixes the twenty named tags believes the job is done.
/// </summary>
[Fact]
public void The_skip_tooltip_admits_when_the_sample_is_shorter_than_the_count()
{
var snapshot = Snap("MAIN", "drv-a") with
{
SkippedTags = new SkippedTagReport(500, 137, [new SkippedTag("tag-0", "TagConfig did not map")]),
};
var title = HostsDriverView.Build([snapshot], nodes: null, instances: null)
.Single().Drivers.Single().SkippedTagsTitle();
title.ShouldContain("137 of 500");
title.ShouldContain("+136 more not shown");
}
/// <summary>A complete sample carries no truncation note.</summary>
[Fact]
public void The_skip_tooltip_omits_the_truncation_note_when_the_sample_is_complete()
{
var snapshot = Snap("MAIN", "drv-a") with
{
SkippedTags = new SkippedTagReport(5, 1, [new SkippedTag("tag-0", "TagConfig did not map")]),
};
HostsDriverView.Build([snapshot], nodes: null, instances: null)
.Single().Drivers.Single().SkippedTagsTitle()
.ShouldNotContain("more not shown");
}
/// <summary>
/// A driver polling NONE of its tags gets its own wording — it is the most likely of all to look
/// healthy, because it has no failing reads to report.
/// </summary>
[Fact]
public void A_driver_polling_nothing_is_called_out_as_such()
{
var snapshot = Snap("MAIN", "drv-a") with
{
SkippedTags = new SkippedTagReport(3, 3, [new SkippedTag("a", "r")]),
};
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
row.SkippedTags!.SkippedEverything.ShouldBeTrue();
row.SkippedTagsTitle().ShouldContain("NONE");
}
/// <summary>
/// A driver that cannot report skips keeps null — distinct from one reporting zero. The column
/// renders "—" for the former and "N tags" for the latter, and collapsing them would claim an
/// unreporting driver is positively known to be polling everything.
/// </summary>
[Fact]
public void A_driver_without_a_skip_report_keeps_null_and_claims_nothing()
{
var row = HostsDriverView.Build([Snap("MAIN", "drv-a")], nodes: null, instances: null)
.Single().Drivers.Single();
row.SkippedTags.ShouldBeNull();
row.HasSkippedTags.ShouldBeFalse();
row.SkippedTagsTitle().ShouldBeEmpty();
}
}