feat(415): pure channel-health derivation + health DTO
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user