Files
ersatztv/ErsatzTV.Core/Scheduling/PlaybackOrderSupport.cs
T
timothyandClaude Opus 4.8 0f34c86afa
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 37m32s
feat(403): make unsupported PlaybackOrder loud at build time + tripwire
Adding a new PlaybackOrder was unsafe by construction: three build-time
dispatch sites turned an unknown value into an enumerator silently.
Classic substituted RandomizedMediaCollectionEnumerator (the // TODO
default arm), PlaylistEnumerator had no default arm so the item was
dropped, and BlockPlayoutBuilder's allow-list continue skipped it.
(#70 already made YAML/Scripted log a warning and MultiCollectionGroup
throws.)

- each silent site now logs a Warning naming the order + engine + the
  fallback taken; the fallback itself is preserved so a live channel
  never goes dark on one misconfigured item and scheduler goldens do
  not move.
- PlaylistEnumerator.Create gained an optional Option<ILogger> (it was
  static with no logger -- why the drop was unreportable); loggered
  callers pass it.
- BlockPlayoutBuilder gained an explicit Random arm (it previously
  reached an enumerator only via the coincidental _ => fallback) and a
  loud defensive fallback.
- new PlaybackOrderSupport matrix (per SchedulingEngineKind) + tripwire
  PlaybackOrderSupportTests: Supported ∪ Unsupported must partition the
  enum for every engine, so a new order fails the test until classified.
  BlockPlayoutBuilder consumes the matrix for its allow-list.
- write-path rejection left unchanged (#70 closed the persistence hole;
  the perimeter has been wrong three times per decisions.md); reverse
  _ => None mappings reviewed and deferred (different axis; making them
  loud would warn on legit enumerator types).

docs/decisions.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:23:21 +02:00

135 lines
6.5 KiB
C#

using ErsatzTV.Core.Domain;
using OrderSet = System.Collections.Generic.HashSet<ErsatzTV.Core.Domain.PlaybackOrder>;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// The scheduling engine families that turn a <see cref="PlaybackOrder" /> into an enumerator, and which
/// orders each one actually handles. This is the single declared support matrix for #403.
/// </summary>
/// <remarks>
/// Each engine keeps BOTH a <c>Supported</c> and an <c>Unsupported</c> set, maintained by hand ON PURPOSE:
/// <c>PlaybackOrderSupportTests</c> asserts the two sets partition every <see cref="PlaybackOrder" /> value
/// (union is total, intersection empty), so adding a new order fails that test until it is consciously
/// classified here. Deriving <c>Unsupported</c> as "everything not supported" would let a new order fall
/// through silently — the very defect #403 exists to kill — so it is spelled out instead.
///
/// Membership here mirrors the executable dispatch in each builder (<c>PlayoutBuilder</c>,
/// <c>PlaylistEnumerator</c>, <c>BlockPlayoutBuilder</c>, <c>EnumeratorCache</c> [YAML],
/// <c>SchedulingEngine.EnumeratorForContent</c> [Scripted]); the builders remain the real logic. Only
/// <see cref="SchedulingEngineKind.Block" /> consumes this table at runtime (its allow-list), so the table
/// is not merely test scaffolding. When you add a case to one of those switches, update the matching set
/// here.
/// </remarks>
public static class PlaybackOrderSupport
{
private sealed record EngineSupport(
IReadOnlySet<PlaybackOrder> Supported,
IReadOnlySet<PlaybackOrder> Unsupported);
private static readonly IReadOnlyDictionary<SchedulingEngineKind, EngineSupport> Matrix =
new Dictionary<SchedulingEngineKind, EngineSupport>
{
// PlayoutBuilder.GetMediaCollectionEnumerator switch; default arm falls back to Random (now loud).
[SchedulingEngineKind.Classic] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
},
new OrderSet { PlaybackOrder.None, PlaybackOrder.RandomRotation }),
// PlaylistEnumerator.Create switch; no default arm meant a null enumerator -> silent drop (now loud).
[SchedulingEngineKind.Playlist] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// BlockPlayoutBuilder allow-list (this very set) + the GetEnumerator switch.
[SchedulingEngineKind.Block] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random,
PlaybackOrder.RandomRotation
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// EnumeratorCache (YAML, non-playlist path); unsupported orders LogWarning + None.
[SchedulingEngineKind.Yaml] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// SchedulingEngine.EnumeratorForContent (Scripted); unsupported orders LogWarning + None.
[SchedulingEngineKind.Scripted] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
})
};
public static IReadOnlyCollection<SchedulingEngineKind> Engines => Matrix.Keys.ToList();
/// <summary>The orders <paramref name="engine" /> can turn into an enumerator.</summary>
public static IReadOnlySet<PlaybackOrder> SupportedBy(SchedulingEngineKind engine) => Matrix[engine].Supported;
/// <summary>The orders explicitly known NOT to be handled by <paramref name="engine" />.</summary>
public static IReadOnlySet<PlaybackOrder> UnsupportedBy(SchedulingEngineKind engine) => Matrix[engine].Unsupported;
public static bool IsSupported(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order);
/// <summary>
/// True when <paramref name="order" /> appears in either set for <paramref name="engine" />. A new enum
/// value is classified in neither until a human adds it — which is what the tripwire test asserts.
/// </summary>
public static bool IsClassified(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order) || Matrix[engine].Unsupported.Contains(order);
}