diff --git a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
index 142eda6d5..80ed692fa 100644
--- a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
+++ b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
@@ -29,4 +29,10 @@
+
+
+ Resources/sequential-schedule.schema.json
+
+
+
diff --git a/ErsatzTV.Infrastructure.Tests/Scheduling/SequentialScheduleValidatorTests.cs b/ErsatzTV.Infrastructure.Tests/Scheduling/SequentialScheduleValidatorTests.cs
new file mode 100644
index 000000000..b0092e41b
--- /dev/null
+++ b/ErsatzTV.Infrastructure.Tests/Scheduling/SequentialScheduleValidatorTests.cs
@@ -0,0 +1,200 @@
+using System.Reflection;
+using ErsatzTV.Core;
+using ErsatzTV.Infrastructure.Scheduling;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+using Testably.Abstractions.Testing;
+
+namespace ErsatzTV.Infrastructure.Tests.Scheduling;
+
+[TestFixture]
+public class SequentialScheduleValidatorTests
+{
+ private readonly string _schema;
+
+ public SequentialScheduleValidatorTests()
+ {
+ var assembly = Assembly.GetAssembly(typeof(SequentialScheduleValidatorTests));
+ assembly.ShouldNotBeNull();
+
+ using var stream = assembly.GetManifestResourceStream(
+ "ErsatzTV.Infrastructure.Tests.Resources.sequential-schedule.schema.json");
+ stream.ShouldNotBeNull();
+
+ using var reader = new StreamReader(stream);
+ _schema = reader.ReadToEnd();
+ }
+
+ [CancelAfter(2_000)]
+ [Test]
+ public async Task ValidateSchedule_Should_Succeed_Valid_Schedule(CancellationToken cancellationToken)
+ {
+ const string YAML =
+"""
+content:
+ - show:
+ key: "SOME_SHOW"
+ guids:
+ - source: "imdb"
+ value: "tt123456"
+ order: chronological
+ - search:
+ key: "FILLER"
+ query: "type:other_video"
+ order: "shuffle"
+
+reset:
+ - wait_until: '8:00am'
+ tomorrow: false
+ rewind_on_reset: true
+
+playout:
+ - duration: "30 minutes"
+ content: "SOME_SHOW"
+ discard_attempts: 2
+ offline_tail: false
+
+ - epg_group: true
+ advance: false
+
+ - pad_to_next: 30
+ content: "FILLER"
+ filler_kind: postroll
+ trim: true
+
+ - epg_group: false
+
+ - repeat: true
+""";
+
+ string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "sequential-schedule.schema.json");
+
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize()
+ .WithFile(schemaFileName).Which(f => f.HasStringContent(_schema));
+
+ var validator = new SequentialScheduleValidator(
+ fileSystem,
+ Substitute.For>());
+
+ bool result = await validator.ValidateSchedule(YAML, false);
+ result.ShouldBeTrue();
+ }
+
+ [CancelAfter(2_000)]
+ [Test]
+ public async Task ValidateSchedule_Should_Fail_Invalid_Schedule(CancellationToken cancellationToken)
+ {
+ const string YAML =
+ """
+ content:
+ - show:
+ key: "SOME_SHOW"
+ guids22:
+ - source: "imdb"
+ value: "tt123456"
+ order: chronological
+ - search:
+ key: "FILLER"
+ query: "type:other_video"
+ order: "shuffle"
+
+ reset:
+ - wait_until: '8:00am'
+ tomorrow: false
+ rewind_on_reset: true
+
+ playout:
+ - duration: "30 minutes"
+ content: "SOME_SHOW"
+ discard_attempts: 2
+ offline_tail: false
+
+ - epg_group: true
+ advance: false
+
+ - pad_to_next: 30
+ content: "FILLER"
+ filler_kind: postroll
+ trim: true
+
+ - epg_group: false
+
+ - repeat: true
+ """;
+
+ string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "sequential-schedule.schema.json");
+
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize()
+ .WithFile(schemaFileName).Which(f => f.HasStringContent(_schema));
+
+ var validator = new SequentialScheduleValidator(
+ fileSystem,
+ Substitute.For>());
+
+ bool result = await validator.ValidateSchedule(YAML, false);
+ result.ShouldBeFalse();
+ }
+
+ [CancelAfter(2_000)]
+ [Test]
+ public async Task GetValidationMessages_With_Invalid_Schedule(CancellationToken cancellationToken)
+ {
+ const string YAML =
+ """
+ content:
+ - show:
+ key: "SOME_SHOW"
+ guids22:
+ - source: "imdb"
+ value: "tt123456"
+ order: chronological
+ - search:
+ key: "FILLER"
+ query: "type:other_video"
+ order: "shuffle"
+
+ reset:
+ - wait_until: '8:00am'
+ tomorrow: false
+ rewind_on_reset: true
+
+ playout:
+ - duration: "30 minutes"
+ content: "SOME_SHOW"
+ discard_attempts: 2
+ offline_tail: false
+
+ - epg_group: true
+ advance: false
+
+ - pad_to_next: 30
+ content: "FILLER"
+ filler_kind: postroll
+ trim: true
+
+ - epg_group: false
+
+ - repeat: true
+ """;
+
+ string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "sequential-schedule.schema.json");
+
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize()
+ .WithFile(schemaFileName).Which(f => f.HasStringContent(_schema));
+
+ var validator = new SequentialScheduleValidator(
+ fileSystem,
+ Substitute.For>());
+
+ IList result = await validator.GetValidationMessages(YAML, false);
+ result.ShouldNotBeNull();
+ result.Count.ShouldBe(1);
+ result[0].ShouldContain("line 3");
+ result[0].ShouldContain("position 5");
+ }
+}
diff --git a/ErsatzTV.Infrastructure/Scheduling/SequentialScheduleValidator.cs b/ErsatzTV.Infrastructure/Scheduling/SequentialScheduleValidator.cs
index 1b5e6421c..3062aec4e 100644
--- a/ErsatzTV.Infrastructure/Scheduling/SequentialScheduleValidator.cs
+++ b/ErsatzTV.Infrastructure/Scheduling/SequentialScheduleValidator.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.IO.Abstractions;
using System.Text.Json;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Scheduling;
@@ -11,7 +12,8 @@ using JsonSerializer = System.Text.Json.JsonSerializer;
namespace ErsatzTV.Infrastructure.Scheduling;
-public class SequentialScheduleValidator(ILogger logger) : ISequentialScheduleValidator
+public class SequentialScheduleValidator(IFileSystem fileSystem, ILogger logger)
+ : ISequentialScheduleValidator
{
public async Task ValidateSchedule(string yaml, bool isImport)
{
@@ -20,7 +22,7 @@ public class SequentialScheduleValidator(ILogger lo
string schemaFileName = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
isImport ? "sequential-schedule-import.schema.json" : "sequential-schedule.schema.json");
- using StreamReader sr = File.OpenText(schemaFileName);
+ using StreamReader sr = fileSystem.File.OpenText(schemaFileName);
await using var reader = new JsonTextReader(sr);
var schema = JSchema.Load(reader);
@@ -63,7 +65,7 @@ public class SequentialScheduleValidator(ILogger lo
string schemaFileName = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
isImport ? "sequential-schedule-import.schema.json" : "sequential-schedule.schema.json");
- using StreamReader sr = File.OpenText(schemaFileName);
+ using StreamReader sr = fileSystem.File.OpenText(schemaFileName);
await using var reader = new JsonTextReader(sr);
var schema = JSchema.Load(reader);