Files
ersatztv/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs
T
timothyandClaude Opus 5 3951fcf516
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 3951fcf
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
fix(823): stop patching the record by grep — a second ?? [] survived, and my "swept it" claim was false
Round-four review. One HIGH, again in the decision record, and the previous
commit message asserted this exact class was cleared. It was not.

THE HIGH, and the reason it recurred.

A second sentence still described the rejected reading: "The guard form is
`?? []` into a local rather than this record's Optional(x).Flatten(), a STATED
deviation". The shipped guard is `?? AllDaysOfWeek()`. That sentence is the one
that dictates guard FORM to the next implementer, so it would have taught the
`[]` reading the same record spends a paragraph calling data corruption -- and
it had already propagated into docs/decisions/README.md, the mandated entry
point, which carries `rule:` verbatim.

The mechanism, not the sentence, is the defect. I swept with a regex keyed on
"null" plus a reading word; this sentence talks about guard FORM and contains
neither, so it could not match. That is grepping the retracted WORDING instead
of sweeping the CONCEPT, which is exactly what this corpus warns about -- and
three rounds in a row have now found a defect introduced by the previous
round's targeted string edit. So the fix is not another targeted edit: the
whole `rule:` field was split into its 39 sentences and read back one by one
against the code. Everything below came out of that pass rather than a grep.

Its secondary damage is worth recording because it is the shape of a rationale
that outlives its claim: the deviation was justified by ".ToList() allocates
for nothing", which is now BOTH irrelevant to the choice AND false about the
shipped code, since AllDaysOfMonth()/AllMonthsOfYear() are themselves
Enumerable.Range(...).ToList() on exactly the null path it describes.

- The opening sentence of `rule:` prescribed Optional(x).Flatten() as THE
  read-site form. It is the sentence most likely to be read in isolation, and
  it is wrong for six of the eight columns. It now separates the universal half
  (a LOCAL, never assigned back) from the half that is not (the substituted
  value), and names where each applies.
- `signals:` had never been touched, so roughly 60% of `rule:` was unreachable
  by the discovery surface built for it -- no AlternateScheduleSelector, no
  mapper, no "unrestricted", and its paths: list named none of the files this
  work touched. It also advertised "Optional Flatten hoisted local" as the
  form, which is precisely what the six do NOT use.
- The body prose was still entirely about SongMetadata while `rule:` had grown
  a whole second subject. Added the two results that contradicted the prior
  reasoning, in prose, where a reader meets them.

A REAL BUG in my own guard, not just prose:

  fixture.IsAbstract.ShouldBeFalse(...)

A C# `static class` compiles to `abstract sealed`, and NUnit runs tests
declared in one -- this repo already has such a fixture
(AlternateScheduleSelectorTests is `public static class`). So the check I added
one commit ago to reject an un-runnable fixture would have falsely reddened a
perfectly good static one. Now rejects an abstract BASE (abstract and NOT
sealed), which is the case NUnit actually cannot instantiate.

A SURVIVING MUTANT the added controls did not kill:

AnyDate was 2024-03-06. With a day <= 12 a CROSS-WIRED substitution survives
the whole fixture -- `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which
still contains day 6, so every assertion passes while the guard substitutes the
wrong set. Moved to 2024-03-20, still a Wednesday in March, outside 1..12.
Measured both ways rather than reasoned: the cross-wire mutant passes the old
fixture and FAILS 2 of 11 on the new one.

Also re-witnessed, because I had modified that file and never re-proved it:
restoring `??=` in LuceneSearchIndex reddens the LUCENE fixture (1 red, 1
green) -- the exact mirror of the Elastic mutation. Extracting
ThrowOnWarningLogger did not cost #701 its proof, and the two fixtures are
independently load-bearing in both directions.

The record is now 73 prose lines, over the 60-line WARNING ceiling. Stated
rather than trimmed: it is 42nd of 42 records over that line, and the added
content is distinct findings (a second subject, a migration analysis and three
residuals), not redundancy against a sibling.

Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves), Core.Tests 697/1 -- 0 failures. Format clean, no BOM. decisions
validate OK.

Refs #823
Refs #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
2026-08-29 21:00:22 +02:00

1106 lines
42 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
public static class AlternateScheduleSelectorTests
{
[TestFixture]
public class GetScheduleForDate
{
private static readonly TimeSpan Offset = TimeSpan.FromHours(-5);
[Test]
public void LimitToDateRange_Before_Start_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
EndMonth = 6,
EndDay = 15
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 3, 31, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Before_Start_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
StartYear = 2024,
EndMonth = 6,
EndDay = 15,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 3, 31, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_On_Start_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
EndMonth = 6,
EndDay = 15
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_On_Start_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
StartYear = 2024,
EndMonth = 6,
EndDay = 15,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_In_Range_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
EndMonth = 6,
EndDay = 15
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 20, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_In_Range_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
StartYear = 2024,
EndMonth = 6,
EndDay = 15,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 20, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_On_End_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
EndMonth = 6,
EndDay = 15
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 15, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_On_End_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
StartYear = 2023,
EndMonth = 6,
EndDay = 15,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 15, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_After_End_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
EndMonth = 6,
EndDay = 15
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 16, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_After_End_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 4,
StartDay = 1,
StartYear = 2024,
EndMonth = 6,
EndDay = 15,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 16, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Before_Invalid_Start_Date_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
EndMonth = 6,
EndDay = 15
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Before_Invalid_Start_Date_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
StartYear = 2023,
EndMonth = 6,
EndDay = 15,
EndYear = 2023
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_After_Invalid_Start_Date_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
EndMonth = 6,
EndDay = 15
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_After_Invalid_Start_Date_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
StartYear = 2023,
EndMonth = 6,
EndDay = 15,
EndYear = 2023
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Before_Invalid_End_Date_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 1,
StartDay = 1,
EndMonth = 2,
EndDay = 29
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Before_Invalid_End_Date_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 1,
StartDay = 1,
StartYear = 2023,
EndMonth = 2,
EndDay = 29,
EndYear = 2023
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_After_Invalid_End_Date_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 1,
StartDay = 1,
EndMonth = 2,
EndDay = 29
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_After_Invalid_End_Date_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 1,
StartDay = 1,
StartYear = 2023,
EndMonth = 2,
EndDay = 29,
EndYear = 2023
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_Before_Start_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
EndMonth = 4,
EndDay = 1
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 14, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_Before_Start_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
StartYear = 2023,
EndMonth = 4,
EndDay = 1,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 14, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_On_Start_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
EndMonth = 4,
EndDay = 1
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 15, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_On_Start_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
StartYear = 2024,
EndMonth = 4,
EndDay = 1,
EndYear = 2025
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 6, 15, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_In_Range_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
EndMonth = 4,
EndDay = 1
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 7, 20, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_In_Range_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
StartYear = 2024,
EndMonth = 4,
EndDay = 1,
EndYear = 2025
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 7, 20, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_On_End_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
EndMonth = 4,
EndDay = 1
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_On_End_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
StartYear = 2023,
EndMonth = 4,
EndDay = 1,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_After_End_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
EndMonth = 4,
EndDay = 1
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 2, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_After_End_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 6,
StartDay = 15,
StartYear = 2023,
EndMonth = 4,
EndDay = 1,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2024, 4, 2, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_Before_Invalid_Start_Date_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
EndMonth = 1,
EndDay = 1
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_Before_Invalid_Start_Date_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
StartYear = 2023,
EndMonth = 1,
EndDay = 1,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_After_Invalid_Start_Date_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
EndMonth = 1,
EndDay = 1
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_After_Invalid_Start_Date_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 2,
StartDay = 29,
StartYear = 2023,
EndMonth = 1,
EndDay = 1,
EndYear = 2024
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_Before_Invalid_End_Date_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 5,
StartDay = 1,
EndMonth = 2,
EndDay = 29
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_Before_Invalid_End_Date_With_Year_Should_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 5,
StartDay = 1,
StartYear = 2022,
EndMonth = 2,
EndDay = 29,
EndYear = 2023
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 2, 28, 0, 0, 0, Offset));
result.IsSome.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_After_Invalid_End_Date_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 5,
StartDay = 1,
EndMonth = 2,
EndDay = 29
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_Wrap_Around_After_Invalid_End_Date_With_Year_Should_Not_Return_Template()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 5,
StartDay = 1,
StartYear = 2022,
EndMonth = 2,
EndDay = 29,
EndYear = 2023
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsNone.ShouldBeTrue();
}
[Test]
public void LimitToDateRange_December_32nd_Should_Not_Crash()
{
var template = new PlayoutTemplate
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = true,
StartMonth = 12,
StartDay = 32,
StartYear = 2022,
EndMonth = 12,
EndDay = 32,
EndYear = 2023
};
Option<PlayoutTemplate> result = AlternateScheduleSelector.GetScheduleForDate(
new List<PlayoutTemplate> { template },
new DateTimeOffset(2023, 3, 1, 0, 0, 0, Offset));
result.IsNone.ShouldBeFalse();
}
}
/// <summary>
/// ersatztv#823. <c>DaysOfWeek</c>, <c>DaysOfMonth</c> and <c>MonthsOfYear</c> on
/// <see cref="PlayoutTemplate" /> and <see cref="ProgramScheduleAlternate" /> are six
/// single-column primitive collections whose columns are <c>nullable: true</c> on both providers.
/// A NULL column materializes as CLR <c>null</c> — EF does not invoke the value converter for a
/// NULL at all — so unguarded, each <c>.Contains</c> in
/// <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> throws
/// <see cref="NullReferenceException" />. These tests are RED without the read-site guard.
/// <para>
/// A null reads as UNRESTRICTED (the <c>All*()</c> sets), not as empty. The deciding case is
/// SQLite's <c>20240113140741_Add_PlayoutTemplate_DaysOfMonth</c>, which adds the column
/// <c>nullable: true</c> with NO default: a row inserted before it had no day-of-month
/// restriction, so reading its NULL as empty would INVERT its meaning and silently stop the
/// template applying. That is the one NULL reachable without any code writing one.
/// </para>
/// <para>
/// Reachability itself is pinned by
/// <c>ErsatzTV.Tests.Integration.SchedulingCollectionColumnNullTests</c> against a real
/// <c>TvContext</c>; these tests pin what the selector does once the null is there.
/// </para>
/// </summary>
[TestFixture]
public class GetScheduleForDate_NullCollections
{
private static readonly TimeSpan Offset = TimeSpan.FromHours(-5);
// A Wednesday in March, so no All*() member is coincidentally excluded — and deliberately the
// 20th rather than the 6th. With a day <= 12 a CROSS-WIRED substitution survives the whole
// fixture: `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which still contains day 6, so
// every assertion here passes while the guard substitutes the wrong set. Day 20 is outside 1..12
// and kills it.
private static readonly DateTimeOffset AnyDate = new(2024, 3, 20, 0, 0, 0, Offset);
private static PlayoutTemplate Unrestricted() =>
new()
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
};
private static Option<PlayoutTemplate> Select(params PlayoutTemplate[] templates) =>
AlternateScheduleSelector.GetScheduleForDate(templates.ToList(), AnyDate);
[Test]
public void Null_DaysOfWeek_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
Select(template).IsSome.ShouldBeTrue(
"a NULL DaysOfWeek means no weekday restriction was recorded, so the template still applies");
}
[Test]
public void Null_DaysOfMonth_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfMonth = null!;
Select(template).IsSome.ShouldBeTrue();
}
[Test]
public void Null_MonthsOfYear_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.MonthsOfYear = null!;
Select(template).IsSome.ShouldBeTrue();
}
[Test]
public void All_Three_Null_On_ProgramScheduleAlternate_Reads_As_Unrestricted()
{
var alternate = new ProgramScheduleAlternate
{
DaysOfWeek = null!,
DaysOfMonth = null!,
MonthsOfYear = null!
};
AlternateScheduleSelector.GetScheduleForDate(
new List<ProgramScheduleAlternate> { alternate },
AnyDate)
.IsSome.ShouldBeTrue();
}
/// <summary>
/// THE DISCRIMINATING CONTROL. Every test above sets a NULL and expects the item to be selected,
/// so all of them pass equally under "NULL means unrestricted" and under the much broader
/// "any NULL makes this item match unconditionally" — a refactor that short-circuits the whole
/// date check when any dimension is null keeps them green. Here the nulled dimension is paired
/// with a RESTRICTIVE non-null one that the date fails, so only the narrow reading passes.
/// </summary>
[Test]
public void A_Null_Dimension_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
// AnyDate is in MARCH; restrict to January only.
template.MonthsOfYear = [1];
Select(template).IsNone.ShouldBeTrue(
"a NULL DaysOfWeek relaxes ONLY the weekday dimension — the January restriction still "
+ "excludes a March date");
}
/// <summary>
/// One arrangement is not enough: with only the <c>DaysOfWeek</c> case above, a PER-DIMENSION
/// mutant survives the whole fixture — e.g. <c>if (item.MonthsOfYear is null) return item;</c>
/// placed ahead of the checks is never reached by that test, because its <c>MonthsOfYear</c> is
/// non-null. So each of the three dimensions is nulled in turn against a restriction on a
/// DIFFERENT dimension.
/// </summary>
[Test]
public void A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.MonthsOfYear = null!;
// AnyDate is a WEDNESDAY; restrict to Monday only.
template.DaysOfWeek = [DayOfWeek.Monday];
Select(template).IsNone.ShouldBeTrue(
"a NULL MonthsOfYear relaxes ONLY the month dimension — the Monday restriction still "
+ "excludes a Wednesday");
}
/// <summary>
/// The third of the per-dimension controls — see
/// <see cref="A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions" /> for why one
/// arrangement is not enough. Here the nulled dimension is <c>DaysOfMonth</c> and the
/// restriction that must still bite is on <c>MonthsOfYear</c>.
/// </summary>
[Test]
public void A_Null_DaysOfMonth_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfMonth = null!;
// AnyDate is in MARCH; restrict to January only.
template.MonthsOfYear = [1];
Select(template).IsNone.ShouldBeTrue(
"a NULL DaysOfMonth relaxes ONLY the day-of-month dimension — the January restriction "
+ "still excludes a March date");
}
/// <summary>
/// A null must not be confused with an explicitly EMPTY collection. Empty is a legal, reachable
/// state meaning "matches no day", and it keeps that meaning — which is exactly why a NULL
/// cannot be normalized to it.
/// </summary>
[Test]
public void An_Explicitly_Empty_Collection_Still_Matches_Nothing()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = [];
Select(template).IsNone.ShouldBeTrue(
"an empty DaysOfWeek is a recorded restriction of NO days, unlike a NULL");
}
/// <summary>
/// The guard resolves PER ITEM: a null on the first item must not decide the second. Making the
/// nulled item genuinely non-matching is what measures that — with an unrestricted nulled item
/// at index 0 it simply wins on ordering and the second item is never evaluated, so the
/// invariant would go unmeasured while the test passed.
/// </summary>
[Test]
public void A_Null_On_One_Item_Does_Not_Decide_A_Later_Item()
{
PlayoutTemplate nulled = Unrestricted();
nulled.DaysOfWeek = null!;
nulled.MonthsOfYear = [1]; // AnyDate is in March, so this item must NOT match
nulled.Index = 0;
PlayoutTemplate second = Unrestricted();
second.Index = 1;
foreach (PlayoutTemplate selected in Select(nulled, second))
{
selected.ShouldBeSameAs(second);
return;
}
Assert.Fail("the loop stopped at the null-collection item instead of continuing to the next");
}
/// <summary>
/// ...and when the nulled item IS unrestricted it legitimately wins on ordering. Paired with the
/// test above so "index 0 wins" and "the loop continues past a non-matching null item" are
/// separately pinned.
/// </summary>
[Test]
public void An_Unrestricted_Null_Item_Wins_On_Index_Order()
{
PlayoutTemplate nulled = Unrestricted();
nulled.DaysOfWeek = null!;
nulled.Index = 0;
PlayoutTemplate second = Unrestricted();
second.Index = 1;
foreach (PlayoutTemplate selected in Select(nulled, second))
{
selected.ShouldBeSameAs(nulled);
return;
}
Assert.Fail("the null-collection item was skipped instead of read as unrestricted");
}
/// <summary>
/// The read-site guard must not be written BACK onto the item. These are single-column
/// primitive collections, so assigning the guard would flip a tracked entity to
/// <c>Modified</c> and the next <c>SaveChanges</c> would persist the substituted collection
/// over the NULL — the mechanism recorded as <c>media.nullable-primitive-collection-mutation</c>.
/// </summary>
[Test]
public void Guard_Must_Not_Be_Written_Back_Onto_The_Item()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
template.DaysOfMonth = null!;
template.MonthsOfYear = null!;
Select(template);
template.DaysOfWeek.ShouldBeNull();
template.DaysOfMonth.ShouldBeNull();
template.MonthsOfYear.ShouldBeNull();
}
}
}