Browse Source

`YamlPlayoutScheduleItem` now implements `IAlternateScheduleItem`

pull/2935/head
Carson Kompon 1 month ago
parent
commit
c7eab469e6
  1. 168
      ErsatzTV.Core.Tests/Scheduling/SequentialScheduleSelectorTests.cs
  2. 102
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs
  3. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs
  4. 121
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs

168
ErsatzTV.Core.Tests/Scheduling/SequentialScheduleSelectorTests.cs

@ -1,168 +0,0 @@ @@ -1,168 +0,0 @@
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
public static class SequentialScheduleSelectorTests
{
[TestFixture]
public class GetActiveSchedule
{
private static readonly TimeSpan Offset = TimeSpan.FromHours(-5);
[Test]
public void Should_Return_None_When_No_Schedules()
{
Option<YamlPlayoutScheduleItem> result = SequentialScheduleSelector.GetActiveSchedule(
new List<YamlPlayoutScheduleItem>(),
new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void Should_Match_Annual_Range_Every_Year()
{
var april = new YamlPlayoutScheduleItem
{
Name = "April",
StartDate = "04-01",
EndDate = "04-30"
};
SequentialScheduleSelector
.GetActiveSchedule([april], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset))
.IfNone(() => null).ShouldBe(april);
SequentialScheduleSelector
.GetActiveSchedule([april], new DateTimeOffset(2030, 4, 1, 0, 0, 0, Offset))
.IfNone(() => null).ShouldBe(april);
}
[Test]
public void Should_Not_Match_Annual_Range_Outside_Dates()
{
var april = new YamlPlayoutScheduleItem
{
Name = "April",
StartDate = "04-01",
EndDate = "04-30"
};
SequentialScheduleSelector
.GetActiveSchedule([april], new DateTimeOffset(2025, 5, 1, 0, 0, 0, Offset))
.IsNone.ShouldBeTrue();
}
[Test]
public void Should_Match_Specific_Year_Only()
{
var christmas = new YamlPlayoutScheduleItem
{
Name = "Christmas 2025",
StartDate = "2025-12-25",
EndDate = "2025-12-25"
};
SequentialScheduleSelector
.GetActiveSchedule([christmas], new DateTimeOffset(2025, 12, 25, 8, 0, 0, Offset))
.IfNone(() => null).ShouldBe(christmas);
SequentialScheduleSelector
.GetActiveSchedule([christmas], new DateTimeOffset(2026, 12, 25, 8, 0, 0, Offset))
.IsNone.ShouldBeTrue();
}
[Test]
public void Should_Match_Annual_Range_That_Wraps_Year_Boundary()
{
var winter = new YamlPlayoutScheduleItem
{
Name = "Winter",
StartDate = "12-01",
EndDate = "01-15"
};
SequentialScheduleSelector
.GetActiveSchedule([winter], new DateTimeOffset(2025, 12, 20, 0, 0, 0, Offset))
.IfNone(() => null).ShouldBe(winter);
SequentialScheduleSelector
.GetActiveSchedule([winter], new DateTimeOffset(2026, 1, 10, 0, 0, 0, Offset))
.IfNone(() => null).ShouldBe(winter);
SequentialScheduleSelector
.GetActiveSchedule([winter], new DateTimeOffset(2026, 6, 1, 0, 0, 0, Offset))
.IsNone.ShouldBeTrue();
}
[Test]
public void Should_Prefer_Higher_Priority_On_Overlap()
{
var december = new YamlPlayoutScheduleItem
{
Name = "December",
StartDate = "12-01",
EndDate = "12-31",
Priority = 0
};
var christmasDay = new YamlPlayoutScheduleItem
{
Name = "Christmas Day",
StartDate = "12-25",
EndDate = "12-25",
Priority = 10
};
SequentialScheduleSelector
.GetActiveSchedule([december, christmasDay], new DateTimeOffset(2025, 12, 25, 12, 0, 0, Offset))
.IfNone(() => null).ShouldBe(christmasDay);
SequentialScheduleSelector
.GetActiveSchedule([december, christmasDay], new DateTimeOffset(2025, 12, 20, 12, 0, 0, Offset))
.IfNone(() => null).ShouldBe(december);
}
[Test]
public void Should_Break_Priority_Ties_By_Definition_Order()
{
var first = new YamlPlayoutScheduleItem
{
Name = "First",
StartDate = "04-01",
EndDate = "04-30",
Priority = 5
};
var second = new YamlPlayoutScheduleItem
{
Name = "Second",
StartDate = "04-01",
EndDate = "04-30",
Priority = 5
};
SequentialScheduleSelector
.GetActiveSchedule([first, second], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset))
.IfNone(() => null).ShouldBe(first);
}
[Test]
public void Should_Not_Match_Invalid_Or_Missing_Dates()
{
var invalid = new YamlPlayoutScheduleItem
{
Name = "Invalid",
StartDate = "not-a-date",
EndDate = "04-30"
};
SequentialScheduleSelector
.GetActiveSchedule([invalid], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset))
.IsNone.ShouldBeTrue();
}
}
}

102
ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
using System.Globalization;
using ErsatzTV.Core.Domain.Scheduling;
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models;
public class YamlPlayoutScheduleItem
public class YamlPlayoutScheduleItem : IAlternateScheduleItem
{
public string Name { get; set; }
@ -15,4 +17,102 @@ public class YamlPlayoutScheduleItem @@ -15,4 +17,102 @@ public class YamlPlayoutScheduleItem
public int Priority { get; set; }
public List<YamlPlayoutInstruction> Playout { get; set; } = [];
[YamlIgnore]
public int Index => -Priority;
// schedules are purely date-range based, so every day/month is eligible
[YamlIgnore]
public ICollection<DayOfWeek> DaysOfWeek => Enum.GetValues<DayOfWeek>();
[YamlIgnore]
public ICollection<int> DaysOfMonth => Enumerable.Range(1, 31).ToList();
[YamlIgnore]
public ICollection<int> MonthsOfYear => Enumerable.Range(1, 12).ToList();
[YamlIgnore]
public bool LimitToDateRange => true;
[YamlIgnore]
public int StartMonth => Range.StartMonth;
[YamlIgnore]
public int StartDay => Range.StartDay;
[YamlIgnore]
public int? StartYear => Range.StartYear;
[YamlIgnore]
public int EndMonth => Range.EndMonth;
[YamlIgnore]
public int EndDay => Range.EndDay;
[YamlIgnore]
public int? EndYear => Range.EndYear;
private NormalizedRange Range => NormalizedRange.From(StartDate, EndDate);
private readonly record struct NormalizedRange(
int StartMonth,
int StartDay,
int? StartYear,
int EndMonth,
int EndDay,
int? EndYear)
{
// an impossible specific-year range (year 1) so an invalid schedule never matches a real date
private static readonly NormalizedRange Invalid = new(1, 1, 1, 1, 1, 1);
public static NormalizedRange From(string startValue, string endValue)
{
ParsedDate start = ParsedDate.Parse(startValue);
ParsedDate end = ParsedDate.Parse(endValue);
if (!start.Valid || !end.Valid)
{
return Invalid;
}
// a specific-year range requires a year on both endpoints, otherwise it repeats annually
if (start.Year.HasValue && end.Year.HasValue)
{
return new NormalizedRange(start.Month, start.Day, start.Year, end.Month, end.Day, end.Year);
}
return new NormalizedRange(start.Month, start.Day, null, end.Month, end.Day, null);
}
}
private readonly record struct ParsedDate(bool Valid, int? Year, int Month, int Day)
{
public static ParsedDate Parse(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return default;
}
if (DateOnly.TryParseExact(
value.Trim(),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out DateOnly specific))
{
return new ParsedDate(true, specific.Year, specific.Month, specific.Day);
}
string[] parts = value.Trim().Split('-');
if (parts.Length == 2
&& int.TryParse(parts[0], out int month)
&& int.TryParse(parts[1], out int day))
{
return new ParsedDate(true, null, month, day);
}
return default;
}
}
}

2
ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs

@ -278,7 +278,7 @@ public class SequentialPlayoutBuilder( @@ -278,7 +278,7 @@ public class SequentialPlayoutBuilder(
// select the active schedule (if any) for the current time; a null name means the default playout
string activeScheduleName = null;
foreach (YamlPlayoutScheduleItem activeSchedule in
SequentialScheduleSelector.GetActiveSchedule(context.Definition.Schedules, context.CurrentTime))
AlternateScheduleSelector.GetScheduleForDate(context.Definition.Schedules, context.CurrentTime))
{
activeScheduleName = activeSchedule.Name;
}

121
ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs

@ -1,121 +0,0 @@ @@ -1,121 +0,0 @@
using System.Globalization;
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
namespace ErsatzTV.Core.Scheduling.YamlScheduling;
public static class SequentialScheduleSelector
{
private static readonly string[] SpecificFormats = ["yyyy-MM-dd"];
private static readonly string[] AnnualFormats = ["MM-dd"];
/// <summary>
/// Returns the highest-priority schedule whose date range contains the given time.
/// Ties on priority are broken by definition order (earlier wins).
/// </summary>
public static Option<YamlPlayoutScheduleItem> GetActiveSchedule(
IReadOnlyList<YamlPlayoutScheduleItem> schedules,
DateTimeOffset time)
{
if (schedules is null || schedules.Count == 0)
{
return Option<YamlPlayoutScheduleItem>.None;
}
var date = DateOnly.FromDateTime(time.LocalDateTime);
Option<YamlPlayoutScheduleItem> best = Option<YamlPlayoutScheduleItem>.None;
int bestPriority = int.MinValue;
for (var i = 0; i < schedules.Count; i++)
{
YamlPlayoutScheduleItem schedule = schedules[i];
if (!Matches(schedule, date))
{
continue;
}
// strictly greater keeps the earliest schedule on ties
if (schedule.Priority > bestPriority)
{
bestPriority = schedule.Priority;
best = schedule;
}
}
return best;
}
private static bool Matches(YamlPlayoutScheduleItem schedule, DateOnly date)
{
ParsedDate? start = ParseDate(schedule.StartDate);
ParsedDate? end = ParseDate(schedule.EndDate);
if (start is null || end is null)
{
return false;
}
// a date is "specific" if either endpoint includes a year
bool specific = start.Value.Year.HasValue || end.Value.Year.HasValue;
if (specific)
{
int startYear = start.Value.Year ?? end.Value.Year ?? date.Year;
int endYear = end.Value.Year ?? start.Value.Year ?? date.Year;
try
{
var startDate = new DateOnly(startYear, start.Value.Month, start.Value.Day);
var endDate = new DateOnly(endYear, end.Value.Month, end.Value.Day);
return date >= startDate && date <= endDate;
}
catch (ArgumentOutOfRangeException)
{
// e.g. Feb 29 in a non-leap year
return false;
}
}
// annual range: compare by month/day so it repeats every year
(int Month, int Day) startMd = (start.Value.Month, start.Value.Day);
(int Month, int Day) endMd = (end.Value.Month, end.Value.Day);
(int Month, int Day) currentMd = (date.Month, date.Day);
if (CompareMonthDay(startMd, endMd) <= 0)
{
// range does not wrap the year boundary
return CompareMonthDay(currentMd, startMd) >= 0 && CompareMonthDay(currentMd, endMd) <= 0;
}
// range wraps the year boundary (e.g. 12-01 through 01-15)
return CompareMonthDay(currentMd, startMd) >= 0 || CompareMonthDay(currentMd, endMd) <= 0;
}
private static int CompareMonthDay((int Month, int Day) left, (int Month, int Day) right)
{
int monthComparison = left.Month.CompareTo(right.Month);
return monthComparison != 0 ? monthComparison : left.Day.CompareTo(right.Day);
}
private static ParsedDate? ParseDate(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
value = value.Trim();
if (DateOnly.TryParseExact(value, SpecificFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly specific))
{
return new ParsedDate(specific.Year, specific.Month, specific.Day);
}
if (DateOnly.TryParseExact(value, AnnualFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly annual))
{
return new ParsedDate(null, annual.Month, annual.Day);
}
return null;
}
private readonly record struct ParsedDate(int? Year, int Month, int Day);
}
Loading…
Cancel
Save