Files
scadaproj/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/StatusDerivationTests.cs
T
Joseph Doherty d0110cf406 feat(overview): scaffold the family dashboard + registry + health client
Phase 3 tasks 3.1-3.3 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md.

Scaffold (3.1): a plain directory in scadaproj (NOT a nested git repo), slnx over
src/ + tests/, HistorianGateway-pattern Directory.Build.props (warnings-as-errors)
and nuget.config (nuget.org * + the Gitea feed scoped to ZB.MOM.WW.*). Inline
package pins, no CPM — the app convention here. NO Auth/Audit/Secrets packages:
the dashboard is anonymous and read-only by requirement.

Registry (3.2): OverviewOptions/ApplicationEntry/InstanceEntry bound from the
"Overview" section, plus EffectiveTimings resolving instance > application >
global overrides in ONE place so no caller re-implements the precedence.
OverviewOptionsValidator (OptionsValidatorBase + AddValidatedOptions) rejects an
empty registry, applications with no instances, duplicate names, non-absolute or
non-http(s) URLs, and non-positive intervals — and checks stale > poll on the
EFFECTIVE values, since an override at either level can invert that on one
instance while the globals look fine. A ConfigPreflight presence check runs before
the host is built so a missing registry fails with the key name, not with
"Applications must have at least 1 entry".

Health client (3.3): ZbHealthReport DTOs for the canonical ZbHealthWriter body,
with per-entry `data` kept as JsonElement — it is an open contract, and binding it
to concrete types would break the moment a check adds a field. 200 AND 503 are
both parseable answers (503 carries the body naming the failing check); only a
transport failure or a non-canonical body is a failed probe, which is why
Unreachable stays distinct from Down. Host shutdown propagates as cancellation
rather than being recorded as every instance timing out.

Status model: Up/Degraded/Down/Unreachable + Active/Standby/Unknown/NotApplicable,
with two-strike flap damping — a failing observation only replaces a good state
after 2 consecutive failures, while recovery is immediate (damping suppresses
false alarms; symmetric damping would just make them linger).

53 tests green, 0 warnings in Debug and Release. Tests for the validator, the
client golden payloads (with AND without `data`, so a partly-bumped fleet renders)
and the derivation table are front-loaded here from task 3.8 so this batch is
verified rather than pending.

Also pins AngleSharp 1.5.2 test-side: bunit 1.40.0 pulls 1.2.0, which trips NU1902
and therefore the warnings-as-errors gate (same fix HistorianGateway made in
6bc005d).
2026-07-24 06:42:21 -04:00

156 lines
6.1 KiB
C#

using ZB.MOM.WW.Overview.Polling;
namespace ZB.MOM.WW.Overview.Tests;
/// <summary>
/// Status derivation and two-strike flap damping. These rules decide what an operator sees, so
/// every branch is pinned: the difference between Down and Unreachable sends someone to a
/// different place entirely, and damping that fires the wrong way either hides a real outage or
/// cries wolf on every dropped packet.
/// </summary>
public class StatusDerivationTests
{
private static HealthProbeResult Reachable(string reportStatus, int statusCode) =>
new(true, statusCode, new ZbHealthReport { Status = reportStatus }, TimeSpan.FromMilliseconds(5), null);
private static HealthProbeResult Unreachable(string error) =>
new(false, null, null, TimeSpan.FromMilliseconds(5), error);
[Theory]
[InlineData("Healthy", 200, InstanceStatus.Up)]
[InlineData("Degraded", 200, InstanceStatus.Degraded)]
[InlineData("Unhealthy", 503, InstanceStatus.Down)]
public void Observe_MapsCanonicalStatuses(string reportStatus, int code, InstanceStatus expected)
{
Assert.Equal(expected, StatusDerivation.Observe(Reachable(reportStatus, code)));
}
[Fact]
public void Observe_TransportFailure_IsUnreachable_NotDown()
{
// The distinction is the whole point: Down means "the app answered and said it is sick",
// Unreachable means "nothing answered" — usually VPN, firewall or a registry typo.
Assert.Equal(InstanceStatus.Unreachable, StatusDerivation.Observe(Unreachable("Connection refused")));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Weird")]
public void Observe_UnrecognisedStatus_IsUnreachable(string? reportStatus)
{
// Parsed as JSON but not this contract — something else is on that port.
Assert.Equal(InstanceStatus.Unreachable, StatusDerivation.Observe(Reachable(reportStatus!, 200)));
}
[Theory]
[InlineData(false, 200, ActiveState.NotApplicable)]
[InlineData(false, 503, ActiveState.NotApplicable)]
public void ObserveActive_WithoutActiveRole_IsNotApplicable(bool hasRole, int code, ActiveState expected)
{
Assert.Equal(expected, StatusDerivation.ObserveActive(hasRole, Reachable("Healthy", code)));
}
[Theory]
[InlineData(200, ActiveState.Active)]
[InlineData(503, ActiveState.Standby)]
[InlineData(404, ActiveState.Unknown)]
public void ObserveActive_MapsStatusCodes(int code, ActiveState expected)
{
Assert.Equal(expected, StatusDerivation.ObserveActive(true, Reachable("Healthy", code)));
}
[Fact]
public void ObserveActive_NoAnswer_IsUnknown_NotStandby()
{
// Guessing Standby on a failed probe would let a pair render with zero Active nodes during
// a blip, or — worse, if guessed the other way — with two.
Assert.Equal(ActiveState.Unknown, StatusDerivation.ObserveActive(true, Unreachable("timed out")));
Assert.Equal(ActiveState.Unknown, StatusDerivation.ObserveActive(true, probe: null));
}
[Fact]
public void Damp_FirstFailureFromGoodState_HoldsThePreviousStatus()
{
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 0, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Up, status);
Assert.Equal(1, failures);
}
[Fact]
public void Damp_SecondConsecutiveFailure_Flips()
{
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 1, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Unreachable, status);
Assert.Equal(2, failures);
}
[Fact]
public void Damp_RecoveryIsImmediate_EvenAfterManyFailures()
{
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Down, 27, InstanceStatus.Up);
Assert.Equal(InstanceStatus.Up, status);
Assert.Equal(0, failures);
}
[Fact]
public void Damp_FirstEverPollThatFails_IsAdoptedImmediately()
{
// No previous state means nothing to protect and nothing better to show. Holding "Up" here
// would be inventing a healthy reading that was never observed.
var (status, failures) = StatusDerivation.Damp(previous: null, 0, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Unreachable, status);
Assert.Equal(1, failures);
}
[Fact]
public void Damp_AlternatingFailures_NeverFlip()
{
// The flapping link this exists for: fail, recover, fail, recover. The counter must reset
// on every good poll, so the card stays Up instead of oscillating.
InstanceStatus? status = InstanceStatus.Up;
var failures = 0;
foreach (var observed in new[]
{
InstanceStatus.Unreachable, InstanceStatus.Up,
InstanceStatus.Unreachable, InstanceStatus.Up,
InstanceStatus.Unreachable, InstanceStatus.Up,
})
{
(var next, failures) = StatusDerivation.Damp(status, failures, observed);
status = next;
}
Assert.Equal(InstanceStatus.Up, status);
Assert.Equal(0, failures);
}
[Fact]
public void Damp_DegradedIsNotAFailure_AndIsAdoptedImmediately()
{
// Degraded is a real answer from a live app, not a failed probe: it must show at once, and
// must not accumulate strikes toward Down.
var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 1, InstanceStatus.Degraded);
Assert.Equal(InstanceStatus.Degraded, status);
Assert.Equal(0, failures);
}
[Fact]
public void Damp_TwoDifferentFailureKindsInARow_StillFlips()
{
// Down then Unreachable is two consecutive failures, not one of each kind restarting the
// count — the instance is not answering healthily either way.
var (first, failures) = StatusDerivation.Damp(InstanceStatus.Up, 0, InstanceStatus.Down);
Assert.Equal(InstanceStatus.Up, first);
var (second, _) = StatusDerivation.Damp(first, failures, InstanceStatus.Unreachable);
Assert.Equal(InstanceStatus.Unreachable, second);
}
}