diff --git a/ErsatzTV.Core.Tests/Scheduling/SequentialScheduleSelectorTests.cs b/ErsatzTV.Core.Tests/Scheduling/SequentialScheduleSelectorTests.cs deleted file mode 100644 index e1eba0a41..000000000 --- a/ErsatzTV.Core.Tests/Scheduling/SequentialScheduleSelectorTests.cs +++ /dev/null @@ -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 result = SequentialScheduleSelector.GetActiveSchedule( - new List(), - 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(); - } - } -} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs index 853e0e2ab..be1896e39 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutScheduleItem.cs @@ -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 public int Priority { get; set; } public List Playout { get; set; } = []; + + [YamlIgnore] + public int Index => -Priority; + + // schedules are purely date-range based, so every day/month is eligible + [YamlIgnore] + public ICollection DaysOfWeek => Enum.GetValues(); + + [YamlIgnore] + public ICollection DaysOfMonth => Enumerable.Range(1, 31).ToList(); + + [YamlIgnore] + public ICollection 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; + } + } } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs index 67b42dbed..97e1f8bac 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs @@ -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; } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs deleted file mode 100644 index e5632822d..000000000 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialScheduleSelector.cs +++ /dev/null @@ -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"]; - - /// - /// Returns the highest-priority schedule whose date range contains the given time. - /// Ties on priority are broken by definition order (earlier wins). - /// - public static Option GetActiveSchedule( - IReadOnlyList schedules, - DateTimeOffset time) - { - if (schedules is null || schedules.Count == 0) - { - return Option.None; - } - - var date = DateOnly.FromDateTime(time.LocalDateTime); - - Option best = Option.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); -}