30d0697c28
`IHostConnectivityProbe` was a dead surface: eleven drivers implement it, `GetHostStatuses()` had ZERO production call sites, and `OnHostStatusChanged` had no subscriber outside the Galaxy driver's own aggregator. Per-host connectivity was computed by every driver and read by nobody. The issue offered "build the publisher or delete it". The publisher as its entity doc described it — driver nodes upserting `DriverHostStatus` rows — is not buildable: per-cluster mesh Phase 4 gates `AddOtOpcUaConfigDb` on the `admin` role, so a driver-only node has no ConfigDb connection to write rows with. So the capability is kept and the transport changed. `DriverHealthChanged.HostStatuses` now carries the probe result to `/hosts` as a Hosts column. That channel already reached the page, already survives the mesh split via the Phase 5 gRPC telemetry stream, and already replays a last-value snapshot on re-subscribe — so per-host state re-primes after a reconnect without a durable store. Both halves of the interface finally do what they are for: the event triggers a prompt publish, the pull is the source of truth. The point of the column is the case the driver-level state chip structurally cannot express: a multi-device driver stays aggregate-Healthy while ONE of its devices is unreachable. Two traps, both pinned by tests that were falsified against the prod code: - The host digest MUST be in the publish fingerprint. On a single-host-down transition every other fingerprint component is unchanged, so the dedup would swallow exactly the publish carrying the news — the trap that already bit the rediscovery signal. Removing it turns the guard test red, verified. - null (no probe) must stay distinct from empty (probe with no hosts). proto3 cannot tell an absent repeated field from an empty one, hence the explicit `has_host_statuses` flag; collapsing them would render every probe-less driver as one whose devices are all fine. Dropped: the DriverHostStatus entity, enum, DbSet, model config and table (migration DropDriverHostStatusTable — empty on every deployment, so the scaffolder's data-loss warning is moot, and Down() recreates it exactly). Found en route, NOT fixed here: `DriverInstanceResilienceStatus` is the identical defect — no writer, no reader, only a DbSet declaration, while the live data rides the `driver-resilience-status` telemetry channel. Its doc-comment now states that rather than describing the sampler and AdminUI join that were never built. Filed as #524 rather than widening this schema change beyond what was asked. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
435 lines
17 KiB
C#
435 lines
17 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Shouldly;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry;
|
|
// Aliased, not imported wholesale: Core.Abstractions also declares a DriverHealth, which collides with
|
|
// the proto DriverHealth these tests construct.
|
|
using HostState = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostState;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="TelemetryProtoMapCentral"/> — the central-side proto→domain
|
|
/// projection. Each kind asserts every field round-trips, including the null-vs-absent presence
|
|
/// handling for nullable Timestamps, optional strings, and the tri-state optional bool. A coverage
|
|
/// guard proves every <see cref="TelemetryProtoContract.HandledCases"/> value has a converter.
|
|
/// </summary>
|
|
public sealed class TelemetryProtoMapCentralTests
|
|
{
|
|
private static readonly DateTime SampleUtc =
|
|
new(2026, 7, 23, 10, 30, 45, DateTimeKind.Utc);
|
|
|
|
private static readonly DateTime OtherUtc =
|
|
new(2026, 7, 23, 9, 15, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public void ToAlarm_transcribes_every_field_with_nullables_present()
|
|
{
|
|
var proto = new AlarmTransition
|
|
{
|
|
AlarmId = "Plant/Modbus/dev1/Speed",
|
|
EquipmentPath = "Area/Line/Equip",
|
|
AlarmName = "HighSpeed",
|
|
TransitionKind = "Activated",
|
|
Severity = 700,
|
|
Message = "Speed high",
|
|
User = "operator1",
|
|
TimestampUtc = Timestamp.FromDateTime(SampleUtc),
|
|
AlarmTypeName = "LimitAlarm",
|
|
Comment = "ack comment",
|
|
HistorizeToAveva = false,
|
|
};
|
|
proto.ReferencingEquipmentPaths.AddRange(["Area/Line/E1", "Area/Line/E2"]);
|
|
|
|
var e = TelemetryProtoMapCentral.ToAlarm(proto);
|
|
|
|
e.AlarmId.ShouldBe("Plant/Modbus/dev1/Speed");
|
|
e.EquipmentPath.ShouldBe("Area/Line/Equip");
|
|
e.AlarmName.ShouldBe("HighSpeed");
|
|
e.TransitionKind.ShouldBe("Activated");
|
|
e.Severity.ShouldBe(700);
|
|
e.Message.ShouldBe("Speed high");
|
|
e.User.ShouldBe("operator1");
|
|
e.TimestampUtc.ShouldBe(SampleUtc);
|
|
e.TimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
|
e.AlarmTypeName.ShouldBe("LimitAlarm");
|
|
e.Comment.ShouldBe("ack comment");
|
|
e.HistorizeToAveva.ShouldBe(false);
|
|
e.ReferencingEquipmentPaths.ShouldBe(["Area/Line/E1", "Area/Line/E2"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToAlarm_absent_optionals_map_to_null_and_empty_repeated_to_empty_list()
|
|
{
|
|
var proto = new AlarmTransition
|
|
{
|
|
AlarmId = "a",
|
|
EquipmentPath = "p",
|
|
AlarmName = "n",
|
|
TransitionKind = "Cleared",
|
|
Severity = 1,
|
|
Message = "m",
|
|
User = "system",
|
|
TimestampUtc = Timestamp.FromDateTime(SampleUtc),
|
|
AlarmTypeName = "AlarmCondition",
|
|
// Comment, HistorizeToAveva unset; no referencing paths added.
|
|
};
|
|
|
|
var e = TelemetryProtoMapCentral.ToAlarm(proto);
|
|
|
|
e.Comment.ShouldBeNull();
|
|
e.HistorizeToAveva.ShouldBeNull();
|
|
e.ReferencingEquipmentPaths.ShouldNotBeNull();
|
|
e.ReferencingEquipmentPaths.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ToAlarm_historize_present_true_round_trips()
|
|
{
|
|
var proto = new AlarmTransition
|
|
{
|
|
AlarmId = "a", EquipmentPath = "p", AlarmName = "n", TransitionKind = "Activated",
|
|
Severity = 1, Message = "m", User = "system",
|
|
TimestampUtc = Timestamp.FromDateTime(SampleUtc), AlarmTypeName = "AlarmCondition",
|
|
HistorizeToAveva = true,
|
|
};
|
|
|
|
TelemetryProtoMapCentral.ToAlarm(proto).HistorizeToAveva.ShouldBe(true);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToScript_transcribes_every_field_with_optionals_present()
|
|
{
|
|
var proto = new ScriptLog
|
|
{
|
|
ScriptId = "script-1",
|
|
Level = "Information",
|
|
Message = "hello",
|
|
TimestampUtc = Timestamp.FromDateTime(SampleUtc),
|
|
VirtualTagId = "vt-1",
|
|
AlarmId = "al-1",
|
|
EquipmentId = "eq-1",
|
|
};
|
|
|
|
var e = TelemetryProtoMapCentral.ToScript(proto);
|
|
|
|
e.ScriptId.ShouldBe("script-1");
|
|
e.Level.ShouldBe("Information");
|
|
e.Message.ShouldBe("hello");
|
|
e.TimestampUtc.ShouldBe(SampleUtc);
|
|
e.TimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
|
e.VirtualTagId.ShouldBe("vt-1");
|
|
e.AlarmId.ShouldBe("al-1");
|
|
e.EquipmentId.ShouldBe("eq-1");
|
|
}
|
|
|
|
[Fact]
|
|
public void ToScript_absent_optionals_map_to_null()
|
|
{
|
|
var proto = new ScriptLog
|
|
{
|
|
ScriptId = "script-1",
|
|
Level = "Error",
|
|
Message = "boom",
|
|
TimestampUtc = Timestamp.FromDateTime(SampleUtc),
|
|
// VirtualTagId, AlarmId, EquipmentId unset.
|
|
};
|
|
|
|
var e = TelemetryProtoMapCentral.ToScript(proto);
|
|
|
|
e.VirtualTagId.ShouldBeNull();
|
|
e.AlarmId.ShouldBeNull();
|
|
e.EquipmentId.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ToHealth_transcribes_every_field_with_nullables_present()
|
|
{
|
|
var proto = new DriverHealth
|
|
{
|
|
ClusterId = "c1",
|
|
DriverInstanceId = "d1",
|
|
State = "Faulted",
|
|
LastSuccessfulReadUtc = Timestamp.FromDateTime(OtherUtc),
|
|
LastError = "timeout",
|
|
ErrorCount5Min = 3,
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
};
|
|
|
|
var e = TelemetryProtoMapCentral.ToHealth(proto);
|
|
|
|
e.ClusterId.ShouldBe("c1");
|
|
e.DriverInstanceId.ShouldBe("d1");
|
|
e.State.ShouldBe("Faulted");
|
|
e.LastSuccessfulReadUtc.ShouldBe(OtherUtc);
|
|
e.LastSuccessfulReadUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
|
|
e.LastError.ShouldBe("timeout");
|
|
e.ErrorCount5Min.ShouldBe(3);
|
|
e.PublishedUtc.ShouldBe(SampleUtc);
|
|
e.PublishedUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-host connectivity (Gitea #521) survives the wire with its tri-state intact:
|
|
/// <b>null</b> (driver has no probe) must stay distinguishable from an <b>empty list</b> (it has one
|
|
/// that knows no hosts). proto3 cannot tell an absent repeated field from an empty one, which is why
|
|
/// <c>has_host_statuses</c> exists — without it a driver with no probe would arrive looking like one
|
|
/// whose devices are all fine, and the /hosts column would render "0 hosts" for every driver in the
|
|
/// fleet.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ToHealth_host_statuses_round_trip_with_the_null_vs_empty_distinction_intact()
|
|
{
|
|
var populated = new DriverHealth
|
|
{
|
|
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
HasHostStatuses = true,
|
|
HostStatuses =
|
|
{
|
|
new HostConnectivity { HostName = "plc-a", State = "Running", LastChangedUtc = Timestamp.FromDateTime(OtherUtc) },
|
|
new HostConnectivity { HostName = "plc-b", State = "Stopped", LastChangedUtc = Timestamp.FromDateTime(SampleUtc) },
|
|
},
|
|
};
|
|
|
|
var mapped = TelemetryProtoMapCentral.ToHealth(populated).HostStatuses;
|
|
mapped.ShouldNotBeNull();
|
|
mapped!.Count.ShouldBe(2);
|
|
mapped[0].HostName.ShouldBe("plc-a");
|
|
mapped[0].State.ShouldBe(HostState.Running);
|
|
mapped[0].LastChangedUtc.ShouldBe(OtherUtc);
|
|
mapped[1].State.ShouldBe(HostState.Stopped);
|
|
|
|
// A probe that currently knows no hosts: empty, NOT null.
|
|
var empty = new DriverHealth
|
|
{
|
|
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
HasHostStatuses = true,
|
|
};
|
|
TelemetryProtoMapCentral.ToHealth(empty).HostStatuses.ShouldBeEmpty();
|
|
|
|
// No probe at all: null, NOT empty.
|
|
var absent = new DriverHealth
|
|
{
|
|
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
};
|
|
TelemetryProtoMapCentral.ToHealth(absent).HostStatuses.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// An unparseable host state degrades to <see cref="HostState.Unknown"/> rather than throwing. A node
|
|
/// running a newer build that added an enum member must not be able to kill central's telemetry
|
|
/// stream — this is observability, and it has no business failing closed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ToHealth_unknown_host_state_string_degrades_instead_of_throwing()
|
|
{
|
|
var proto = new DriverHealth
|
|
{
|
|
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
HasHostStatuses = true,
|
|
HostStatuses = { new HostConnectivity { HostName = "plc-a", State = "Quiescing" } },
|
|
};
|
|
|
|
var mapped = TelemetryProtoMapCentral.ToHealth(proto).HostStatuses;
|
|
|
|
mapped.ShouldNotBeNull();
|
|
mapped![0].State.ShouldBe(HostState.Unknown);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToHealth_absent_nullable_timestamp_and_optional_string_map_to_null()
|
|
{
|
|
var proto = new DriverHealth
|
|
{
|
|
ClusterId = "c1",
|
|
DriverInstanceId = "d1",
|
|
State = "Healthy",
|
|
ErrorCount5Min = 0,
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
// LastSuccessfulReadUtc, LastError unset.
|
|
};
|
|
|
|
var e = TelemetryProtoMapCentral.ToHealth(proto);
|
|
|
|
e.LastSuccessfulReadUtc.ShouldBeNull();
|
|
e.LastError.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ToResilience_transcribes_every_field_with_nullable_present()
|
|
{
|
|
var proto = new DriverResilienceStatus
|
|
{
|
|
DriverInstanceId = "d1",
|
|
HostName = "host-a",
|
|
BreakerOpen = true,
|
|
ConsecutiveFailures = 5,
|
|
CurrentInFlight = 2,
|
|
LastBreakerOpenUtc = Timestamp.FromDateTime(OtherUtc),
|
|
LastSampledUtc = Timestamp.FromDateTime(SampleUtc),
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
};
|
|
|
|
var e = TelemetryProtoMapCentral.ToResilience(proto);
|
|
|
|
e.DriverInstanceId.ShouldBe("d1");
|
|
e.HostName.ShouldBe("host-a");
|
|
e.BreakerOpen.ShouldBeTrue();
|
|
e.ConsecutiveFailures.ShouldBe(5);
|
|
e.CurrentInFlight.ShouldBe(2);
|
|
e.LastBreakerOpenUtc.ShouldBe(OtherUtc);
|
|
e.LastBreakerOpenUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
|
|
e.LastSampledUtc.ShouldBe(SampleUtc);
|
|
e.PublishedUtc.ShouldBe(SampleUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToResilience_absent_nullable_timestamp_maps_to_null()
|
|
{
|
|
var proto = new DriverResilienceStatus
|
|
{
|
|
DriverInstanceId = "d1",
|
|
HostName = "host-a",
|
|
BreakerOpen = false,
|
|
ConsecutiveFailures = 0,
|
|
CurrentInFlight = 0,
|
|
LastSampledUtc = Timestamp.FromDateTime(SampleUtc),
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
// LastBreakerOpenUtc unset.
|
|
};
|
|
|
|
TelemetryProtoMapCentral.ToResilience(proto).LastBreakerOpenUtc.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ToAlarm_missing_required_timestamp_throws_clear_InvalidOperation()
|
|
{
|
|
var proto = new AlarmTransition
|
|
{
|
|
AlarmId = "a", EquipmentPath = "p", AlarmName = "n", TransitionKind = "Activated",
|
|
Severity = 1, Message = "m", User = "system", AlarmTypeName = "AlarmCondition",
|
|
// TimestampUtc deliberately unset — an out-of-contract sender.
|
|
};
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToAlarm(proto));
|
|
ex.Message.ShouldContain("AlarmTransition");
|
|
ex.Message.ShouldContain("timestamp_utc");
|
|
}
|
|
|
|
[Fact]
|
|
public void ToScript_missing_required_timestamp_throws_clear_InvalidOperation()
|
|
{
|
|
var proto = new ScriptLog { ScriptId = "s", Level = "Information", Message = "m" };
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToScript(proto));
|
|
ex.Message.ShouldContain("ScriptLog");
|
|
ex.Message.ShouldContain("timestamp_utc");
|
|
}
|
|
|
|
[Fact]
|
|
public void ToHealth_missing_required_timestamp_throws_clear_InvalidOperation()
|
|
{
|
|
var proto = new DriverHealth
|
|
{
|
|
ClusterId = "c", DriverInstanceId = "d", State = "Healthy", ErrorCount5Min = 0,
|
|
// PublishedUtc unset.
|
|
};
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToHealth(proto));
|
|
ex.Message.ShouldContain("DriverHealth");
|
|
ex.Message.ShouldContain("published_utc");
|
|
}
|
|
|
|
[Fact]
|
|
public void ToResilience_missing_required_timestamp_throws_clear_InvalidOperation()
|
|
{
|
|
var proto = new DriverResilienceStatus
|
|
{
|
|
DriverInstanceId = "d", HostName = "h", BreakerOpen = false,
|
|
ConsecutiveFailures = 0, CurrentInFlight = 0,
|
|
// LastSampledUtc + PublishedUtc unset.
|
|
};
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToResilience(proto));
|
|
ex.Message.ShouldContain("DriverResilienceStatus");
|
|
}
|
|
|
|
[Fact]
|
|
public void MapEvent_routes_each_case_to_its_record_type()
|
|
{
|
|
var alarm = new TelemetryEvent { AlarmTransition = MinimalAlarm() };
|
|
var script = new TelemetryEvent { ScriptLog = MinimalScript() };
|
|
var health = new TelemetryEvent { DriverHealth = MinimalHealth() };
|
|
var resilience = new TelemetryEvent { DriverResilience = MinimalResilience() };
|
|
|
|
TelemetryProtoMapCentral.MapEvent(alarm).ShouldBeOfType<Commons.Messages.Alerts.AlarmTransitionEvent>();
|
|
TelemetryProtoMapCentral.MapEvent(script).ShouldBeOfType<Commons.Messages.Logging.ScriptLogEntry>();
|
|
TelemetryProtoMapCentral.MapEvent(health).ShouldBeOfType<Commons.Messages.Drivers.DriverHealthChanged>();
|
|
TelemetryProtoMapCentral.MapEvent(resilience).ShouldBeOfType<Commons.Messages.Drivers.DriverResilienceStatusChanged>();
|
|
}
|
|
|
|
[Fact]
|
|
public void MapEvent_unset_case_throws_NotSupported()
|
|
{
|
|
Should.Throw<NotSupportedException>(() => TelemetryProtoMapCentral.MapEvent(new TelemetryEvent()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Coverage guard: every <see cref="TelemetryProtoContract.HandledCases"/> value must be routed
|
|
/// by <see cref="TelemetryProtoMapCentral.MapEvent"/> to a non-null domain record. Adding a
|
|
/// fifth oneof case (and thus a fifth HandledCases entry) without a converter arm fails here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MapEvent_covers_every_handled_case()
|
|
{
|
|
foreach (var handled in TelemetryProtoContract.HandledCases)
|
|
{
|
|
var evt = BuildFor(handled);
|
|
evt.EventCase.ShouldBe(handled);
|
|
TelemetryProtoMapCentral.MapEvent(evt).ShouldNotBeNull();
|
|
}
|
|
}
|
|
|
|
private static TelemetryEvent BuildFor(TelemetryEvent.EventOneofCase handled) => handled switch
|
|
{
|
|
TelemetryEvent.EventOneofCase.AlarmTransition => new TelemetryEvent { AlarmTransition = MinimalAlarm() },
|
|
TelemetryEvent.EventOneofCase.ScriptLog => new TelemetryEvent { ScriptLog = MinimalScript() },
|
|
TelemetryEvent.EventOneofCase.DriverHealth => new TelemetryEvent { DriverHealth = MinimalHealth() },
|
|
TelemetryEvent.EventOneofCase.DriverResilience => new TelemetryEvent { DriverResilience = MinimalResilience() },
|
|
_ => throw new InvalidOperationException($"Test does not know how to build case {handled}."),
|
|
};
|
|
|
|
private static AlarmTransition MinimalAlarm() => new()
|
|
{
|
|
AlarmId = "a", EquipmentPath = "p", AlarmName = "n", TransitionKind = "Activated",
|
|
Severity = 1, Message = "m", User = "system",
|
|
TimestampUtc = Timestamp.FromDateTime(SampleUtc), AlarmTypeName = "AlarmCondition",
|
|
};
|
|
|
|
private static ScriptLog MinimalScript() => new()
|
|
{
|
|
ScriptId = "s", Level = "Information", Message = "m",
|
|
TimestampUtc = Timestamp.FromDateTime(SampleUtc),
|
|
};
|
|
|
|
private static DriverHealth MinimalHealth() => new()
|
|
{
|
|
ClusterId = "c", DriverInstanceId = "d", State = "Healthy",
|
|
ErrorCount5Min = 0, PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
};
|
|
|
|
private static DriverResilienceStatus MinimalResilience() => new()
|
|
{
|
|
DriverInstanceId = "d", HostName = "h", BreakerOpen = false,
|
|
ConsecutiveFailures = 0, CurrentInFlight = 0,
|
|
LastSampledUtc = Timestamp.FromDateTime(SampleUtc),
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
};
|
|
}
|