mirror of https://github.com/ErsatzTV/ErsatzTV.git
4 changed files with 102 additions and 291 deletions
@ -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(); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -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…
Reference in new issue