using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Scheduling; using NUnit.Framework; using Shouldly; using PlayoutsMapper = ErsatzTV.Application.Playouts.Mapper; using SchedulingMapper = ErsatzTV.Application.Scheduling.Mapper; namespace ErsatzTV.Tests.Application.Scheduling; /// /// ersatztv#823. Guarding alone would have left the OTHER read /// of the same six columns unguarded — the entity→view-model mappers, which feed /// PlayoutController's response models and therefore the SPA. /// /// Two things break without the guard, and neither is a C# exception, which is why the selector /// tests cannot see them. web/src/screens/PlayoutScheduleEditors.tsx spreads the collection /// (daysOfMonth: [...template.daysOfMonth]) and throws TypeError: not iterable on a /// JSON null; and web/src/screens/playoutTemplateCalendar.ts's appliesToDate — /// an exact TypeScript port of — /// calls .includes on it. /// /// /// So the mappers substitute the SAME unrestricted defaults the selector reads. That agreement is /// the point: a DTO that said "empty" while the selector scheduled "unrestricted" would make the /// preview calendar disagree with the playout it is previewing. /// /// [TestFixture] public class RecurrenceLimitsMapperNullTests { private static Template MinimalTemplate() => new() { Id = 7, Name = "T", TemplateGroupId = 1, TemplateGroup = new TemplateGroup { Name = "G" }, Items = [] }; [Test] public void ProgramScheduleAlternate_Null_Collections_Map_To_Unrestricted() { var alternate = new ProgramScheduleAlternate { Id = 1, Index = 0, ProgramScheduleId = 2, DaysOfWeek = null!, DaysOfMonth = null!, MonthsOfYear = null! }; PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate); vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek()); vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth()); vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear()); // Never assigned back: these are single-column primitive collections, so writing the guard onto a // tracked entity would persist the substituted set over the NULL // (media.nullable-primitive-collection-mutation). alternate.DaysOfWeek.ShouldBeNull(); alternate.DaysOfMonth.ShouldBeNull(); alternate.MonthsOfYear.ShouldBeNull(); } [Test] public void PlayoutTemplate_Null_Collections_Map_To_Unrestricted() { var template = new PlayoutTemplate { Id = 1, Index = 0, Template = MinimalTemplate(), DecoTemplate = null, DaysOfWeek = null!, DaysOfMonth = null!, MonthsOfYear = null! }; PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template); vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek()); vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth()); vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear()); template.DaysOfWeek.ShouldBeNull(); template.DaysOfMonth.ShouldBeNull(); template.MonthsOfYear.ShouldBeNull(); } /// /// An explicitly EMPTY collection is a recorded restriction of no days and must survive the mapper /// unchanged. Without this, a guard written as "empty or null becomes All*" would pass the two tests /// above while silently rewriting real user data on the way out. /// /// There is one of these per MAPPER, not one in total. The two overloads are byte-identical /// triples in different files, so a defensive edit to one alone is exactly the "one helper, two /// callers" shape this repo has been bitten by: covering only the Playouts mapper would leave /// the PlayoutTemplate one free to acquire an `empty-or-null` guard with the suite still green. /// /// [Test] public void An_Explicitly_Empty_Collection_Is_Not_Rewritten_By_The_PlayoutTemplate_Mapper() { var template = new PlayoutTemplate { Id = 1, Index = 0, Template = MinimalTemplate(), DecoTemplate = null, DaysOfWeek = [], DaysOfMonth = [], MonthsOfYear = [] }; PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template); vm.DaysOfWeek.ShouldBeEmpty(); vm.DaysOfMonth.ShouldBeEmpty(); vm.MonthsOfYear.ShouldBeEmpty(); } /// /// The ProgramScheduleAlternate half of the same pair — see the PlayoutTemplate one above /// for why there is one per MAPPER rather than one in total. /// [Test] public void An_Explicitly_Empty_Collection_Is_Not_Rewritten() { var alternate = new ProgramScheduleAlternate { Id = 1, Index = 0, ProgramScheduleId = 2, DaysOfWeek = [], DaysOfMonth = [], MonthsOfYear = [] }; PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate); vm.DaysOfWeek.ShouldBeEmpty(); vm.DaysOfMonth.ShouldBeEmpty(); vm.MonthsOfYear.ShouldBeEmpty(); } }