Files
ersatztv/ErsatzTV.Core.Tests/Channels/ChannelGuideProjectorClockPadTests.cs
T
timothyandClaude Opus 4.8 7e6b9d2747 test(77): characterize clock-boundary schedule padding; docs
#77's core (pad/snap schedules to :00/:15/:30 via filler) already exists —
FillerMode.Pad + PadToNearestMinute (Classic), pad_to_next/pad_until
(Sequential); Block is inherently time-anchored. No production code change;
this locks the behaviour end-to-end and documents that it exists.

- PlayoutBuildGoldenTests.Classic_clock_padded: a PostRoll FillerMode.Pad(15)
  preset through the real PlayoutBuilder snaps content to :15 (golden +
  explicit quarter-hour assertion). Splits Verify -> CompareGolden for reuse.
- ChannelGuideProjectorClockPadTests: the guide projection coalesces trailing
  filler so programmes STOP on the padded boundary (XMLTV half).
- docs: decisions.md (2026-07-17 entry), domain-model.md (clock-boundary row),
  testing.md (test map + count 540->542).

Deferred (UI, blocked on #388): one-click per-channel/schedule clock-align
toggle + 60-min increment option.

Refs #77

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

85 lines
3.9 KiB
C#

using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Channels;
// Issue #77 (XMLTV half): the clock-boundary Pad machinery snaps content to :00/:15/:30/:45 by emitting
// trailing PostRoll filler up to the boundary (proven at the builder level by
// PlayoutBuildGoldenTests.Classic_clock_padded). This test locks the OTHER half: that the guide
// projection — the single source of truth shared by the XMLTV cache builder and the JSON guide query —
// coalesces that trailing filler into the programme window, so each guide programme STOPS on the padded
// clock boundary. Together the two tests cover the full chain: schedule -> PlayoutItems -> guide.
[TestFixture]
public class ChannelGuideProjectorClockPadTests
{
// Deterministic UTC instants mirroring the Classic_clock_padded golden shape: off-boundary content
// (22/37/37-min) each followed by a single PostRoll filler run that ends exactly on the next :15 mark.
private static readonly DateTime Anchor = new(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
[Test]
public void Padded_programmes_stop_on_quarter_hour_boundary()
{
var items = new List<PlayoutItem>
{
// Programme 1: content 00:00-00:22, padded to 00:30 with PostRoll filler.
Content(guideGroup: 1, start: T(0, 0), finish: T(0, 22), title: "Movie 01"),
Pad(guideGroup: 1, start: T(0, 22), finish: T(0, 30)),
// Programme 2: content 00:30-01:07, padded to 01:15.
Content(guideGroup: 2, start: T(0, 30), finish: T(1, 7), title: "Movie 02"),
Pad(guideGroup: 2, start: T(1, 7), finish: T(1, 15)),
// Programme 3: content 01:15-01:52, padded to 02:00.
Content(guideGroup: 3, start: T(1, 15), finish: T(1, 52), title: "Movie 03"),
Pad(guideGroup: 3, start: T(1, 52), finish: T(2, 0))
};
List<ChannelGuideEntry> entries = ChannelGuideProjector
.Project(PlayoutScheduleKind.Classic, items, XmltvTimeZone.Utc, XmltvBlockBehavior.UseActualTimes)
.ToList();
// One programme per content item; trailing filler is coalesced in, not surfaced as its own entry.
entries.Count.ShouldBe(3);
entries.ShouldAllBe(e => e.DisplayItem.FillerKind == FillerKind.None);
// The #77 invariant: every programme STOPS on a :15 clock boundary (the padded target), and the
// 2nd/3rd programmes also START on one (the previous programme padded up to it).
foreach (ChannelGuideEntry entry in entries)
{
(entry.Stop.Minute % 15).ShouldBe(0, $"programme '{entry.DisplayItem.CustomTitle}' stops off-boundary at {entry.Stop:HH:mm:ss}");
entry.Stop.Second.ShouldBe(0);
}
entries[0].Stop.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero));
entries[1].Start.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero));
entries[1].Stop.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero));
entries[2].Start.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero));
entries[2].Stop.ShouldBe(new DateTimeOffset(T(2, 0), TimeSpan.Zero));
}
private static DateTime T(int hour, int minute) => Anchor.AddHours(hour).AddMinutes(minute);
private static PlayoutItem Content(int guideGroup, DateTime start, DateTime finish, string title) =>
new()
{
Start = start,
Finish = finish,
FillerKind = FillerKind.None,
GuideGroup = guideGroup,
CustomTitle = title
};
private static PlayoutItem Pad(int guideGroup, DateTime start, DateTime finish) =>
new()
{
Start = start,
Finish = finish,
FillerKind = FillerKind.PostRoll,
GuideGroup = guideGroup
};
}