feat(415): pure channel-health derivation + health DTO

This commit is contained in:
2026-07-23 22:04:07 +02:00
parent 173cecfaa4
commit b15a77ee74
3 changed files with 282 additions and 0 deletions
+91
View File
@@ -29,6 +29,97 @@ internal static class Mapper
return result;
}
internal static ChannelHealthResponseModel GetHealth(
Channel channel,
int playoutCount,
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
{
if (playoutCount == 0)
{
return new ChannelHealthResponseModel(
ChannelHealthStatus.Problems,
[ChannelFault.NoPlayout],
0,
0);
}
var faults = new System.Collections.Generic.HashSet<string>();
var brokenSourceItemCount = 0;
var sawAssessable = false;
var isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand;
foreach (Playout playout in ContributingPlayouts(channel))
{
upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
brokenSourceItemCount += u.BrokenUpcoming;
bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default;
// Presence signals — always live.
if (built && playout.BuildStatus.Success == false)
{
faults.Add(ChannelFault.BuildFailed);
}
if (u.BrokenUpcoming > 0)
{
faults.Add(ChannelFault.BrokenSource);
}
// Absence signals — suppressed for on-demand (drains between tune-ins).
if (!isOnDemand)
{
if (!built)
{
faults.Add(ChannelFault.NeverBuilt);
}
else if (u.TotalUpcoming == 0)
{
faults.Add(ChannelFault.EmptyUpcoming);
}
else
{
sawAssessable = true;
}
}
else if (built && u.TotalUpcoming > 0)
{
sawAssessable = true;
}
}
string status = faults.Count > 0
? ChannelHealthStatus.Problems
: sawAssessable
? ChannelHealthStatus.Healthy
: ChannelHealthStatus.Unknown;
return new ChannelHealthResponseModel(
status,
faults.ToArray(),
playoutCount,
brokenSourceItemCount);
}
private static IEnumerable<Playout> ContributingPlayouts(Channel channel)
{
if (channel.Playouts is not null)
{
foreach (Playout p in channel.Playouts)
{
yield return p;
}
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
{
foreach (Playout p in channel.MirrorSourceChannel.Playouts)
{
yield return p;
}
}
}
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new(
channel.Id,
@@ -0,0 +1,36 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
// Server-derived per-channel health. The SPA and MCP both read Status/Faults rather than deriving a
// verdict themselves (the api.channel-health-signal decision, superseding #72's raw-fact-only stance).
// Faults are plain strings (see ChannelFault) not a C# enum, to keep the OpenAPI schema simple — the
// SPA hand-maintains its own union, matching the ChannelPreviewAvailability pattern.
public record ChannelHealthResponseModel(
// One of ChannelHealthStatus's values.
string Status,
// The specific fault classes that fired (each a ChannelFault value); empty when Healthy/Unknown.
string[] Faults,
// Retained #72 fact: total playouts (mirror-aware).
int PlayoutCount,
// Count of upcoming built items pointing at a FileNotFound/Unavailable MediaItem; 0 when none.
int BrokenSourceItemCount);
public static class ChannelHealthStatus
{
public const string Healthy = "Healthy";
public const string Problems = "Problems";
public const string Unknown = "Unknown";
}
public static class ChannelFault
{
public const string NoPlayout = "NoPlayout";
public const string NeverBuilt = "NeverBuilt";
public const string BuildFailed = "BuildFailed";
public const string EmptyUpcoming = "EmptyUpcoming";
public const string BrokenSource = "BrokenSource";
}
// Per-playout upcoming-item aggregate (Task 2's repository query fills this). Lives in Core so both
// the repository interface (Core) and Mapper (Application) can reference it.
public readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming);
@@ -0,0 +1,155 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using NUnit.Framework;
using Shouldly;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class ChannelHealthTests
{
private static Channel ChannelWith(
ChannelPlayoutMode mode,
params Playout[] playouts) =>
new(Guid.NewGuid())
{
Id = 1,
Number = "1",
Name = "Test",
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = mode,
Playouts = playouts.ToList()
};
private static Playout BuiltPlayout(
int id,
PlayoutScheduleKind kind = PlayoutScheduleKind.Classic,
bool success = true) =>
new()
{
Id = id,
ScheduleKind = kind,
BuildStatus = new PlayoutBuildStatus
{
PlayoutId = id,
LastBuild = new DateTimeOffset(2026, 7, 23, 12, 0, 0, TimeSpan.Zero),
Success = success
}
};
private static Playout NeverBuiltPlayout(int id) =>
new() { Id = id, ScheduleKind = PlayoutScheduleKind.Classic, BuildStatus = null };
private static IReadOnlyDictionary<int, PlayoutUpcoming> Upcoming(
params (int playoutId, int total, int broken)[] rows) =>
rows.ToDictionary(r => r.playoutId, r => new PlayoutUpcoming(r.total, r.broken));
[Test]
public void No_Playout_Is_Problems_With_NoPlayout_Fault()
{
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous);
ChannelHealthResponseModel health = GetHealth(channel, 0, Upcoming());
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldBe([ChannelFault.NoPlayout]);
}
[Test]
public void Continuous_Never_Built_Is_Problems()
{
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, NeverBuiltPlayout(10));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldContain(ChannelFault.NeverBuilt);
}
[Test]
public void Build_Failed_Is_Problems_In_Any_Mode()
{
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10, success: false));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 5, 0)));
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldContain(ChannelFault.BuildFailed);
}
[Test]
public void Built_But_Zero_Upcoming_Is_EmptyUpcoming_When_Continuous()
{
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
}
[Test]
public void Broken_Upcoming_Item_Is_BrokenSource_With_Count()
{
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 8, 3)));
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldContain(ChannelFault.BrokenSource);
health.BrokenSourceItemCount.ShouldBe(3);
}
[Test]
public void Built_NonEmpty_Clean_Is_Healthy()
{
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 12, 0)));
health.Status.ShouldBe(ChannelHealthStatus.Healthy);
health.Faults.ShouldBeEmpty();
}
[Test]
public void OnDemand_Never_Built_Is_Unknown_Not_Problems()
{
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, NeverBuiltPlayout(10));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
health.Status.ShouldBe(ChannelHealthStatus.Unknown);
health.Faults.ShouldBeEmpty();
}
[Test]
public void OnDemand_Empty_Upcoming_Is_Unknown_Not_Problems()
{
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
health.Status.ShouldBe(ChannelHealthStatus.Unknown);
}
[Test]
public void OnDemand_With_Broken_Upcoming_Is_Problems()
{
Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 4, 2)));
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldContain(ChannelFault.BrokenSource);
}
[Test]
public void Multi_Playout_Unions_Faults_And_Problems_Wins()
{
Channel channel = ChannelWith(
ChannelPlayoutMode.Continuous,
BuiltPlayout(10),
BuiltPlayout(11));
// playout 10 healthy, playout 11 empty -> union has EmptyUpcoming, status Problems
ChannelHealthResponseModel health = GetHealth(channel, 2, Upcoming((10, 5, 0), (11, 0, 0)));
health.Status.ShouldBe(ChannelHealthStatus.Problems);
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
}
// #71 guard: the seam is kind-agnostic — an empty upcoming window flags EmptyUpcoming identically
// regardless of PlayoutScheduleKind (the four non-Classic kinds have no ProgramSchedule).
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
[TestCase(PlayoutScheduleKind.ExternalJson)]
public void Empty_Upcoming_Is_Detected_For_Every_Schedule_Kind(PlayoutScheduleKind kind)
{
Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10, kind));
ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
}
}