Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for ea88801
Review verdict / Set review-verdict status (pull_request_target) Successful in 18s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 4m22s
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
Round-three review findings. One HIGH, and it was in the durable artifact
rather than the code.
THE HIGH: the record stated the shipped reading and its inverse.
The semantic reversal (empty -> unrestricted) rewrote the residual and the
write-half of `media.nullable-primitive-collection-mutation` but left the
ORIGINAL reasoning standing two sentences earlier: "A null reads as EMPTY, so
the item matches nothing"; "the REJECTED alternative was the All*() set";
"SKIPPING the row is the conservative repair". The shipped code is
`?? AllDaysOfWeek()` -- precisely the alternative that passage calls rejected.
The previous commit then inserted residual (1), which reasons entirely FROM
the All*() reading, two sentences after the sentence denying it.
That is worse than a stale comment. A session resolving this key -- or reading
the MemPalace mirror, which carries `rule:` verbatim -- would have been told to
write the guard the other way, i.e. talked into the `[]` reading that the same
record elsewhere argues is data corruption one save later. Replaced the whole
passage, then swept the record for every other mention of the empty reading
rather than trusting the one replacement: the only survivor is the new sentence
that records EMPTY as the rejected alternative, which is the direction that
stops it being re-adopted.
THE MEDIUM: one arrangement did not close the hole it claimed to.
The discriminating control added last commit nulls DaysOfWeek against a
restrictive MonthsOfYear. It excludes "any NULL matches unconditionally" only
for that dimension. The review supplied the surviving mutant --
`if (item.MonthsOfYear is null) { return item; }` ahead of the checks -- and
traced it green through all nine tests. Verified by EXECUTION, not by reading:
applied to the previous fixture it passes; applied now it FAILS 1 of 11. Each
of the three dimensions is now nulled against a restriction on a different
dimension.
The rest, all from the same round:
- The coverage guard's test detection listed attribute TYPES, and each list
falsely reddened whatever it omitted: TestAttribute alone missed [TestCase],
and the three-type replacement missed [Theory]. Now decided by NUnit's own
ITestBuilder/ISimpleTestBuilder interfaces, which cannot fall behind the
vocabulary. It also dropped BindingFlags.Static (GetMethods() defaults to
including it), which would have falsely reddened a static test method.
- The same guard accepted an ABSTRACT fixture -- NUnit never instantiates one.
The indexer population already filtered IsAbstract; the fixture side now
mirrors it.
- The record's `mechanics:` still described the old `[Test]`-only clause, in
the same file the change edited.
- An <inheritdoc> made the ProgramScheduleAlternate empty-case test inherit a
docstring written from the PlayoutTemplate test's viewpoint.
Two more mutations executed:
- `if (item.MonthsOfYear is null) return item;` -> 1 red, 10 green. This is the
mutant that survived the previous head; it no longer does.
- an abstract type named in the covered set -> coverage guard red.
Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves, skipping visibly without ETV_TEST_MYSQL_CONNECTION), Core.Tests 697/1
-- 0 failures. Format clean, no BOM on the touched set. 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
152 lines
5.8 KiB
C#
152 lines
5.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// ersatztv#823. Guarding <see cref="AlternateScheduleSelector" /> alone would have left the OTHER read
|
|
/// of the same six columns unguarded — the entity→view-model mappers, which feed
|
|
/// <c>PlayoutController</c>'s response models and therefore the SPA.
|
|
/// <para>
|
|
/// Two things break without the guard, and neither is a C# exception, which is why the selector
|
|
/// tests cannot see them. <c>web/src/screens/PlayoutScheduleEditors.tsx</c> spreads the collection
|
|
/// (<c>daysOfMonth: [...template.daysOfMonth]</c>) and throws <c>TypeError: not iterable</c> on a
|
|
/// JSON <c>null</c>; and <c>web/src/screens/playoutTemplateCalendar.ts</c>'s <c>appliesToDate</c> —
|
|
/// an exact TypeScript port of <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> —
|
|
/// calls <c>.includes</c> on it.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>ProgramScheduleAlternate</c> half of the same pair — see the PlayoutTemplate one above
|
|
/// for why there is one per MAPPER rather than one in total.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
}
|