#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>
85 lines
3.9 KiB
C#
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
|
|
};
|
|
}
|