39db75083d
A driver builds its RawPath -> definition table at init and tolerates a
per-tag miss: an unmappable TagConfig, an unparseable address, a
capability-matrix rejection. That tolerance is right -- one bad tag must not
take down the other few thousand. What was missing is any statement of the
fact afterwards. A skip is not an error and happens before any read, so the
driver stays Healthy, errors/5 min stays 0, Last error stays empty, and Last
read advances live off the tags that did map. On the wonder Makino in #540
that reading held while focas-z-34184 ignored 20 of its 22 tags.
New optional capability ISkippedTagReporter, backed by a shared
SkippedTagLedger so the reset/commit/cap rules are one implementation rather
than one per driver. The report -- authored count, exact skipped count, and a
capped sample of RawPath + reason -- rides DriverHealthChanged to a new Tags
column on /hosts, over the Phase 5 telemetry stream. No table: a driver-only
node has no ConfigDb to write to, the lesson from #521.
The issue named eight drivers. It is twelve -- every driver in
DriverTypeNames.All has an authored-tag skip path. Calculation (no scriptId),
MTConnect (no readable DataItem id), Galaxy (no attributeRef) and OpcUaClient
(no nodeId) were unlisted, and OpcUaClient's was the quietest of the twelve:
NamespaceMap drops such an entry with a bare `continue` and not even a log
line. SkippedTagReporterFleetTests pins the invariant reflectively over every
IDriver in src/Drivers/, so a thirteenth cannot re-introduce the silence.
Sql drops a tag at TWO points and its report is the union of both: a blob
that does not map, AND an identifier the 8.1 catalog gate rejects against the
live catalog. The second is what schema drift produces -- a renamed column, a
dropped view -- months after a deployment sealed green, and reporting only
the first would have that driver claim "0 skipped" while polling half its
tags, which is worse than reporting nothing because the column would then be
actively reassuring.
Deliberately additive to driver state, never a change of it. Latching
non-Healthy for a config problem would satisfy both halves of the #527
comms-loss conjunction -- 90 s without a successful read AND non-Healthy --
and a driver whose tags were ALL skipped has no successful reads by
construction, so it would reconnect-loop forever against a healthy device.
Three details that are load-bearing rather than incidental:
- The skip digest is part of the publish fingerprint. A skipped tag leaves
every other fingerprint component identical to a clean driver's, so without
it PublishHealthSnapshot's dedup swallows exactly the publish carrying the
signal -- the trap that already bit the rediscovery and host-status
channels. The digest covers the sample, not just the counts: a redeploy
that fixes one tag and breaks another leaves both counts unchanged.
- The count is exact; only the sample is capped, and the tooltip says so when
it is showing fewer than it counted. A truncated list that looked complete
would have an operator fix twenty tags and believe the job done.
- The ledger stages and commits, so an init that throws part-way leaves the
previous report standing. A partial snapshot would read as "we now skip
fewer tags", the opposite of the truth.
The proto field needs no companion presence flag, unlike host_statuses: a
singular message field carries real presence in proto3, and a has_ flag would
collide with protoc's generated HasSkippedTags.
Verified red-first three ways: dropping SkippedTagDigest from the fingerprint
fails the two dedup tests, removing the seam from one driver fails the fleet
guard, and dropping the catalog-gate leg fails the Sql union test. Full
solution builds with zero new warnings against 4e22f024.
491 lines
19 KiB
C#
491 lines
19 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>
|
|
/// The skipped-tag report (Gitea #541) survives the wire with the same tri-state discipline:
|
|
/// <b>null</b> (the driver cannot report skips) must stay distinguishable from a <b>zero report</b>
|
|
/// (it can, and skipped nothing). Unlike the repeated host_statuses field this needs no companion
|
|
/// presence flag — a singular message field carries real presence in proto3 — but the distinction it
|
|
/// protects is the same one, and collapsing it would render an unreporting driver as positively
|
|
/// known-clean.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ToHealth_skipped_tags_round_trip_with_the_null_vs_zero_distinction_intact()
|
|
{
|
|
var populated = new DriverHealth
|
|
{
|
|
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
SkippedTags = new SkippedTags
|
|
{
|
|
AuthoredCount = 22,
|
|
// Deliberately larger than the sample: the cap is on the detail, never on the count.
|
|
SkippedCount = 20,
|
|
Sample =
|
|
{
|
|
new SkippedTag { RawPath = "Makino/Identity/SeriesNumber", Reason = "invalid Address" },
|
|
},
|
|
},
|
|
};
|
|
|
|
var mapped = TelemetryProtoMapCentral.ToHealth(populated).SkippedTags;
|
|
mapped.ShouldNotBeNull();
|
|
mapped!.AuthoredCount.ShouldBe(22);
|
|
mapped.SkippedCount.ShouldBe(20);
|
|
mapped.Sample.Count.ShouldBe(1, "a truncated sample must arrive truncated, not padded or dropped");
|
|
mapped.Sample[0].RawPath.ShouldBe("Makino/Identity/SeriesNumber");
|
|
mapped.Sample[0].Reason.ShouldBe("invalid Address");
|
|
|
|
// A driver that reports, and skipped nothing: a zero report, NOT null.
|
|
var clean = new DriverHealth
|
|
{
|
|
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
SkippedTags = new SkippedTags { AuthoredCount = 12, SkippedCount = 0 },
|
|
};
|
|
var mappedClean = TelemetryProtoMapCentral.ToHealth(clean).SkippedTags;
|
|
mappedClean.ShouldNotBeNull();
|
|
mappedClean!.HasSkips.ShouldBeFalse();
|
|
mappedClean.AuthoredCount.ShouldBe(12);
|
|
|
|
// A driver that cannot report at all: null, NOT a zero report.
|
|
var absentSkips = new DriverHealth
|
|
{
|
|
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
|
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
|
};
|
|
TelemetryProtoMapCentral.ToHealth(absentSkips).SkippedTags.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),
|
|
};
|
|
}
|