mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* Added `schedules` to YAML/Sequenced Playback * Add tests for yaml playback * `YamlPlayoutScheduleItem` now implements `IAlternateScheduleItem` * New tests for YamlPlayoutScheduleItem instead of the old SequentialScheduleSelector * add more safety and a couple tests * fix shuffle_sequence instruction handler --------- Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>pull/2937/head
10 changed files with 673 additions and 18 deletions
@ -0,0 +1,77 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Scheduling.YamlScheduling; |
||||||
|
using ErsatzTV.Core.Scheduling.YamlScheduling.Models; |
||||||
|
using NUnit.Framework; |
||||||
|
using Shouldly; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.Scheduling; |
||||||
|
|
||||||
|
public static class YamlPlayoutContextTests |
||||||
|
{ |
||||||
|
[TestFixture] |
||||||
|
public class ScheduleSwitching |
||||||
|
{ |
||||||
|
private static YamlPlayoutContext CreateContext() |
||||||
|
{ |
||||||
|
var definition = new YamlPlayoutDefinition |
||||||
|
{ |
||||||
|
Playout = [new YamlPlayoutInstruction()], |
||||||
|
Schedules = |
||||||
|
[ |
||||||
|
new YamlPlayoutScheduleItem |
||||||
|
{ |
||||||
|
Name = "Christmas", |
||||||
|
StartDate = "12-25", |
||||||
|
EndDate = "12-25", |
||||||
|
Playout = [new YamlPlayoutInstruction()] |
||||||
|
} |
||||||
|
] |
||||||
|
}; |
||||||
|
|
||||||
|
return new YamlPlayoutContext(new Playout(), definition, 1); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void Switching_Should_Not_Leak_Graphics_Elements_Across_Lists() |
||||||
|
{ |
||||||
|
YamlPlayoutContext context = CreateContext(); |
||||||
|
|
||||||
|
// default playout turns a graphics element on
|
||||||
|
context.SetGraphicsElement(1, null); |
||||||
|
context.GetGraphicsElements().ShouldContainKey(1); |
||||||
|
|
||||||
|
// crossing into the schedule should start with a clean ambient state
|
||||||
|
context.SwitchToSchedule("Christmas"); |
||||||
|
context.GetGraphicsElements().ShouldNotContainKey(1); |
||||||
|
|
||||||
|
// the schedule can turn on the same element without throwing
|
||||||
|
Should.NotThrow(() => context.SetGraphicsElement(1, null)); |
||||||
|
context.GetGraphicsElements().ShouldContainKey(1); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void Returning_To_Default_Should_Restore_Its_Graphics_Elements() |
||||||
|
{ |
||||||
|
YamlPlayoutContext context = CreateContext(); |
||||||
|
|
||||||
|
context.SetGraphicsElement(1, "default-vars"); |
||||||
|
context.SwitchToSchedule("Christmas"); |
||||||
|
context.SetGraphicsElement(1, "christmas-vars"); |
||||||
|
|
||||||
|
// returning to the default playout restores its ambient state
|
||||||
|
context.SwitchToSchedule(null); |
||||||
|
context.GetGraphicsElements().ShouldContainKey(1); |
||||||
|
context.GetGraphicsElements()[1].ShouldBe("default-vars"); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void SetGraphicsElement_Should_Be_Idempotent() |
||||||
|
{ |
||||||
|
YamlPlayoutContext context = CreateContext(); |
||||||
|
|
||||||
|
context.SetGraphicsElement(1, "a"); |
||||||
|
Should.NotThrow(() => context.SetGraphicsElement(1, "b")); |
||||||
|
context.GetGraphicsElements()[1].ShouldBe("b"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,168 @@ |
|||||||
|
using ErsatzTV.Core.Scheduling; |
||||||
|
using ErsatzTV.Core.Scheduling.YamlScheduling.Models; |
||||||
|
using NUnit.Framework; |
||||||
|
using Shouldly; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.Scheduling; |
||||||
|
|
||||||
|
public static class YamlPlayoutScheduleSelectionTests |
||||||
|
{ |
||||||
|
[TestFixture] |
||||||
|
public class GetScheduleForDate |
||||||
|
{ |
||||||
|
private static readonly TimeSpan Offset = TimeSpan.FromHours(-5); |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void Should_Return_None_When_No_Schedules() |
||||||
|
{ |
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate( |
||||||
|
new List<YamlPlayoutScheduleItem>(), |
||||||
|
new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset)) |
||||||
|
.IsNone.ShouldBeTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public void Should_Match_Annual_Range_Every_Year() |
||||||
|
{ |
||||||
|
var april = new YamlPlayoutScheduleItem |
||||||
|
{ |
||||||
|
Name = "April", |
||||||
|
StartDate = "04-01", |
||||||
|
EndDate = "04-30" |
||||||
|
}; |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([april], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset)) |
||||||
|
.IfNone(() => null).ShouldBe(april); |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([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" |
||||||
|
}; |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([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" |
||||||
|
}; |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([christmas], new DateTimeOffset(2025, 12, 25, 8, 0, 0, Offset)) |
||||||
|
.IfNone(() => null).ShouldBe(christmas); |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([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" |
||||||
|
}; |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([winter], new DateTimeOffset(2025, 12, 20, 0, 0, 0, Offset)) |
||||||
|
.IfNone(() => null).ShouldBe(winter); |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([winter], new DateTimeOffset(2026, 1, 10, 0, 0, 0, Offset)) |
||||||
|
.IfNone(() => null).ShouldBe(winter); |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([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 |
||||||
|
}; |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([december, christmasDay], new DateTimeOffset(2025, 12, 25, 12, 0, 0, Offset)) |
||||||
|
.IfNone(() => null).ShouldBe(christmasDay); |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([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 |
||||||
|
}; |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([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" |
||||||
|
}; |
||||||
|
|
||||||
|
AlternateScheduleSelector |
||||||
|
.GetScheduleForDate([invalid], new DateTimeOffset(2025, 4, 15, 12, 0, 0, Offset)) |
||||||
|
.IsNone.ShouldBeTrue(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,118 @@ |
|||||||
|
using System.Globalization; |
||||||
|
using ErsatzTV.Core.Domain.Scheduling; |
||||||
|
using YamlDotNet.Serialization; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; |
||||||
|
|
||||||
|
public class YamlPlayoutScheduleItem : IAlternateScheduleItem |
||||||
|
{ |
||||||
|
public string Name { get; set; } |
||||||
|
|
||||||
|
[YamlMember(Alias = "start_date", ApplyNamingConventions = false)] |
||||||
|
public string StartDate { get; set; } |
||||||
|
|
||||||
|
[YamlMember(Alias = "end_date", ApplyNamingConventions = false)] |
||||||
|
public string EndDate { get; set; } |
||||||
|
|
||||||
|
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 => AlternateScheduleSelector.AllDaysOfWeek(); |
||||||
|
|
||||||
|
[YamlIgnore] |
||||||
|
public ICollection<int> DaysOfMonth => AlternateScheduleSelector.AllDaysOfMonth(); |
||||||
|
|
||||||
|
[YamlIgnore] |
||||||
|
public ICollection<int> MonthsOfYear => AlternateScheduleSelector.AllMonthsOfYear(); |
||||||
|
|
||||||
|
[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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue