Files
ersatztv/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs
T
timothyandClaude Fable 5.1 a7d91bf15a
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only
`docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites
from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep
over every tracked file outside the detector, for the detector's own phrasings plus the attribution
and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass
for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer
proved") added residuals in the same files. Every site was classified with #812's three
dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site
manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and
traps stay; the attribution of who found them and the round in which they were found go.

The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless
of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus
`scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as
the records' own exemption, and what keeps the record's depth measurement true), and `--all`
lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812
argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff`
sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where
narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over
the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same
measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS
matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill
the withdrawn parity test ran on. The population oracle is restated over segments with the new
arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture
witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a
by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process
arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures
exemption, list only markdown, drop the symlink filter, test the mode per row instead of per
path) each
redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++`
filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no
notice — fixed, with a positive control and its own mutant.

Code is unchanged by construction, measured per file type against `origin/main`: Python modules
are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs
(string literals) of three test modules; workflows differ only in `#` lines inside `run:` block
scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated
exceptions: the detector and its test, 26 vitest titles that carried review-round or severity
labels or a reviewer attribution (call sites whose title changed — every changed title line
walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two
registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed.
Web: lint, typecheck, 1319 tests green. Closes #876.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
2026-09-03 20:51:39 +02:00

491 lines
23 KiB
C#

using System.Collections;
using System.Reflection;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
/// <summary>
/// Release gate for issue #126: proves the flat <see cref="ScheduleItemResponseModel" /> produced by
/// the GET endpoint carries enough information to reconstruct an identical PUT (ReplaceProgramScheduleItems)
/// with no loss — a GET → map → PUT → GET fixed point across every subtype and field family. Also documents
/// the deliberate <c>EnforceProperties</c> normalization (shuffle → Dynamic/One/None).
/// </summary>
[TestFixture]
public class ScheduleItemResponseRoundTripTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Get_Map_Replace_Get_Should_Be_Lossless_Across_All_Subtypes()
{
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: false);
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
// Arrange: establish the initial items through the real write path.
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded =
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, BuildSeedItems()), CancellationToken.None);
seeded.IsRight.ShouldBeTrue(seeded.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// Act: GET (with durations) → flat DTO envelope A
ScheduleItemsResponseModel envelopeA = await GetItemsEnvelope(scheduleId);
envelopeA.Items.Count.ShouldBe(6);
// Map every response item back into a Replace command exactly as the request mapping would, then PUT.
// Reconstruct in the item's own (stable) Index order, mirroring how the SPA re-submits the ordered list.
List<ReplaceProgramScheduleItem> reconstructed = envelopeA.Items
.OrderBy(item => item.Index)
.Select((item, index) => ToReplaceCommand(item, index))
.ToList();
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> replaced =
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// GET again → envelope B; A and B must be semantically identical INCLUDING row ids —
// the handler reconciles by id and updates in place, it does not regenerate rows.
ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);
envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
envelopeB.TotalDurationEstimate.ShouldBe(envelopeA.TotalDurationEstimate);
// The GET does not guarantee row order, so pair items up by their (stable) Index before comparing.
List<ScheduleItemResponseModel> orderedA = envelopeA.Items.OrderBy(i => i.Index).ToList();
List<ScheduleItemResponseModel> orderedB = envelopeB.Items.OrderBy(i => i.Index).ToList();
for (var i = 0; i < orderedA.Count; i++)
{
AssertSemanticallyEqual(orderedA[i], orderedB[i]);
}
// Spot-check that the display/hydration fields actually populated (proves the mapper is non-vacuous).
ScheduleItemResponseModel multiple = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Multiple);
multiple.CollectionName.ShouldBe("Prime Collection");
multiple.MultipleMode.ShouldBe(MultipleMode.Count);
multiple.MultipleCount.ShouldBe("2 + 1");
multiple.FillWithGroupMode.ShouldBe(FillWithGroupMode.FillWithShuffledGroups);
multiple.PreRollFillerId.ShouldBe(1);
multiple.PreRollFillerName.ShouldBe("PreRoll");
multiple.FallbackFillerId.ShouldBe(5);
multiple.WatermarkIds.ShouldBe([1, 2]);
multiple.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
multiple.GraphicsElementIds.ShouldBe([1, 2]);
multiple.GraphicsElements.Select(g => g.Id).ShouldBe([1, 2]);
multiple.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
ScheduleItemResponseModel one = envelopeA.Items.Single(i =>
i.PlayoutMode == PlayoutMode.One && i.CollectionType == CollectionType.Collection);
one.StartType.ShouldBe(StartType.Fixed);
one.StartTime.ShouldBe(TimeSpan.FromHours(20));
one.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Flexible);
one.CustomTitle.ShouldBe("News Hour");
one.PreferredAudioLanguageCode.ShouldBe("eng");
one.PreferredAudioTitle.ShouldBe("Director Commentary");
one.PreferredSubtitleLanguageCode.ShouldBe("fra");
one.SubtitleMode.ShouldBe(ChannelSubtitleMode.Any);
ScheduleItemResponseModel duration = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Duration);
duration.SmartCollectionName.ShouldBe("Smart Picks");
duration.PlayoutDuration.ShouldBe(TimeSpan.FromMinutes(45));
duration.TailMode.ShouldBe(TailMode.Filler);
duration.TailFillerId.ShouldBe(4);
duration.PlaybackOrder.ShouldBe(PlaybackOrder.Marathon);
duration.MarathonGroupBy.ShouldBe(MarathonGroupBy.Show);
duration.MarathonBatchSize.ShouldBe(4);
// Seeded with DiscardToFillAttempts = 5, but the write path's FixDiscardToFillAttempts zeroes
// it for any order other than Random/Shuffle (Marathon here) — a deliberate server-side
// normalization. Pinning it here keeps the lossless round-trip honest: A already holds 0, so
// the re-submit maps 0 → 0 and B stays equal.
duration.DiscardToFillAttempts.ShouldBe(0);
ScheduleItemResponseModel flood = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Flood);
flood.PlaylistName.ShouldBe("My Playlist");
flood.PlaylistGroupId.ShouldBe(9);
flood.MarathonShuffleGroups.ShouldBeTrue();
ScheduleItemResponseModel rerun = envelopeA.Items.Single(i => i.CollectionType == CollectionType.RerunFirstRun);
rerun.RerunCollectionName.ShouldBe("Rerun Bin");
ScheduleItemResponseModel search = envelopeA.Items.Single(i => i.CollectionType == CollectionType.SearchQuery);
search.SearchQuery.ShouldBe("genre:comedy");
search.Name.ShouldBe("Comedy Search");
}
[Test]
public async Task Get_Should_Apply_EnforceProperties_Normalization_When_Shuffle_Enabled()
{
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: true);
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded = await replaceHandler.Handle(
new ReplaceProgramScheduleItems(
scheduleId,
[
// Fixed-start Flood item on a plain collection.
MakeReplace(0, PlayoutMode.Flood, CollectionType.Collection) with
{
StartTime = TimeSpan.FromHours(6),
CollectionId = 1,
PlaybackOrder = PlaybackOrder.Chronological
},
// Playlist item carrying a playback order that must be normalized away.
MakeReplace(1, PlayoutMode.One, CollectionType.Playlist) with
{
PlaylistId = 1,
PlaybackOrder = PlaybackOrder.Chronological
}
]),
CancellationToken.None);
seeded.IsRight.ShouldBeTrue();
List<ScheduleItemResponseModel> items = (await GetItemsEnvelope(scheduleId)).Items;
ScheduleItemResponseModel floodItem = items.Single(i => i.CollectionType == CollectionType.Collection);
// Flood → One and Fixed → Dynamic are the documented, deliberate rewrites when ShuffleScheduleItems is on.
floodItem.PlayoutMode.ShouldBe(PlayoutMode.One);
floodItem.StartType.ShouldBe(StartType.Dynamic);
ScheduleItemResponseModel playlistItem = items.Single(i => i.CollectionType == CollectionType.Playlist);
playlistItem.PlaybackOrder.ShouldBe(PlaybackOrder.None);
}
private async Task<ScheduleItemsResponseModel> GetItemsEnvelope(int scheduleId)
{
var itemsHandler = new GetProgramScheduleItemsHandler(_db.Factory);
IMediator mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(ci => itemsHandler.Handle((GetProgramScheduleItems)ci[0], (CancellationToken)ci[1]));
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.GetItems(Arg.Any<int>()).Returns(new List<MediaItem>());
var withDurations = new GetProgramScheduleItemsWithDurationsHandler(mediator, repo);
ProgramScheduleItemsWithDurationViewModel vm =
await withDurations.Handle(new GetProgramScheduleItemsWithDurations(scheduleId), CancellationToken.None);
return ScheduleItemResponseMapper.ProjectToResponseModel(vm);
}
private static List<ReplaceProgramScheduleItem> BuildSeedItems() =>
[
// 0: Fixed-start One on a plain collection, full preferred-audio/subtitle + custom title.
MakeReplace(0, PlayoutMode.One, CollectionType.Collection) with
{
StartType = StartType.Fixed,
StartTime = TimeSpan.FromHours(20),
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
CollectionId = 1,
PlaybackOrder = PlaybackOrder.Chronological,
CustomTitle = "News Hour",
PreferredAudioLanguageCode = "eng",
PreferredAudioTitle = "Director Commentary",
PreferredSubtitleLanguageCode = "fra",
SubtitleMode = ChannelSubtitleMode.Any
},
// 1: Multiple (Count, expression string) on a different collection, group mode + all 5 fillers + 2 wm + 2 ge.
MakeReplace(1, PlayoutMode.Multiple, CollectionType.Collection) with
{
CollectionId = 2,
PlaybackOrder = PlaybackOrder.Shuffle,
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
MultipleMode = MultipleMode.Count,
MultipleCount = "2 + 1",
PreRollFillerId = 1,
MidRollFillerId = 2,
PostRollFillerId = 3,
TailFillerId = 4,
FallbackFillerId = 5,
WatermarkIds = [1, 2],
GraphicsElementIds = [1, 2]
},
// 2: Duration on a smart collection, Marathon order + tail filler.
MakeReplace(2, PlayoutMode.Duration, CollectionType.SmartCollection) with
{
SmartCollectionId = 1,
PlayoutDuration = TimeSpan.FromMinutes(45),
TailMode = TailMode.Filler,
TailFillerId = 4,
DiscardToFillAttempts = 5,
PlaybackOrder = PlaybackOrder.Marathon,
MarathonGroupBy = MarathonGroupBy.Show,
MarathonShuffleGroups = true,
MarathonShuffleItems = true,
MarathonBatchSize = 4
},
// 3: Flood on a playlist (playback order None; MarathonShuffleGroups doubles as shuffle-playlist-items).
MakeReplace(3, PlayoutMode.Flood, CollectionType.Playlist) with
{
PlaylistId = 1,
PlaybackOrder = PlaybackOrder.None,
MarathonShuffleGroups = true
},
// 4: Rerun-first-run One item.
MakeReplace(4, PlayoutMode.One, CollectionType.RerunFirstRun) with
{
RerunCollectionId = 1,
PlaybackOrder = PlaybackOrder.None
},
// 5: SearchQuery One item.
MakeReplace(5, PlayoutMode.One, CollectionType.SearchQuery) with
{
SearchTitle = "Comedy Search",
SearchQuery = "genre:comedy",
PlaybackOrder = PlaybackOrder.Shuffle
}
];
private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode, CollectionType collectionType) =>
new(
null,
index,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
playoutMode,
collectionType,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: null,
SearchQuery: null,
PlaybackOrder: PlaybackOrder.Shuffle,
MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None,
MultipleMode: MultipleMode.Count,
MultipleCount: null,
PlayoutDuration: null,
TailMode: TailMode.None,
DiscardToFillAttempts: null,
CustomTitle: null,
GuideMode: GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
// Mirrors the ScheduleItemRequest → ReplaceProgramScheduleItem controller mapping, but sourced from a
// response DTO (the SPA does this same field-for-field copy in TypeScript before a PUT).
private static ReplaceProgramScheduleItem ToReplaceCommand(ScheduleItemResponseModel r, int index) =>
new(
r.Id,
index,
r.StartType,
r.StartTime,
r.FixedStartTimeBehavior,
r.PlayoutMode,
r.CollectionType,
r.CollectionId,
r.MultiCollectionId,
r.SmartCollectionId,
r.RerunCollectionId,
r.MediaItemId,
r.PlaylistId,
r.SearchTitle,
r.SearchQuery,
r.PlaybackOrder,
r.MarathonGroupBy,
r.MarathonShuffleGroups,
r.MarathonShuffleItems,
r.MarathonBatchSize,
r.FillWithGroupMode,
r.MultipleMode ?? MultipleMode.Count,
r.MultipleCount,
r.PlayoutDuration,
r.TailMode ?? TailMode.None,
r.DiscardToFillAttempts,
r.CustomTitle,
r.GuideMode,
r.PreRollFillerId,
r.MidRollFillerId,
r.PostRollFillerId,
r.TailFillerId,
r.FallbackFillerId,
r.WatermarkIds,
r.GraphicsElementIds,
r.PreferredAudioLanguageCode,
r.PreferredAudioTitle,
r.PreferredSubtitleLanguageCode,
r.SubtitleMode);
// ersatztv#779 (detector G): the compared field list is DERIVED from the DTO by reflection,
// never hand-copied. The previous version was a hand-written run of `b.X.ShouldBe(a.X)` lines.
// It was COMPLETE on the day it was written — every property but Id — and had no way
// to report the day it stopped being: a field added to ScheduleItemResponseModel simply went
// uncompared, and this "lossless round-trip" test kept passing while the round trip silently
// dropped it. That is #754's mechanism exactly (a hand-maintained mirror drifting from a
// 28-property DTO by one field, HTTP 200, no error), one altitude up — in the very test whose
// job is to catch losses.
//
// Properties deliberately NOT compared. The set is EMPTY, and that is a finding rather than an
// oversight. The first version exempted Id on the reasoning that "the PUT replaces the item
// set, so B's rows are new rows with new ids". ReplaceProgramScheduleItemsHandler does not do
// that for this fixture's payload: it forwards every Id, takes the id-based reconcile, and
// updates rows in place. So Id compares equal, and the exemption was unnecessary.
//
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why:
// a confident mechanism for this observation is easy to get wrong, and two independent ones
// were each contradicted by the code:
//
// ToReplaceCommand passes `null` for EVERY id -> test stays GREEN
// ToReplaceCommand passes `null` for index 0 only -> test goes RED, "Id differs"
//
// So the Id comparison does discriminate; it is not decorative. What it is NOT is a substitute
// for ReplaceProgramScheduleItemsReconcileTests, whose
// Reorder_ById_Should_Move_State_With_The_Logical_Item_Not_The_Slot and
// Insert_ById_In_Middle_Should_Keep_Existing_Ids_And_State pass real ids and pin that state
// moves with the logical item rather than the slot. Those are the #252 tests; this is a
// round-trip check that happens to also notice a lost row.
//
// Any name added here must still exist on ScheduleItemResponseModel (asserted below), so
// renaming a field cannot leave a stale exemption silently exempting nothing.
private static readonly System.Collections.Generic.HashSet<string> RoundTripExemptProperties =
new(StringComparer.Ordinal);
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
{
PropertyInfo[] properties = typeof(ScheduleItemResponseModel)
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// A stale exemption is a silent hole: it would exempt nothing while reading as a reviewed
// decision, and the property it once named would be compared or not by accident.
foreach (string exempt in RoundTripExemptProperties)
{
properties.Any(p => p.Name == exempt).ShouldBeTrue(
$"'{exempt}' is exempted from the round-trip comparison but is not a property of "
+ $"{nameof(ScheduleItemResponseModel)}; remove the stale exemption or fix the name.");
}
var compared = 0;
foreach (PropertyInfo property in properties)
{
if (RoundTripExemptProperties.Contains(property.Name))
{
continue;
}
object? expected = property.GetValue(a);
object? actual = property.GetValue(b);
if (expected is IEnumerable expectedSequence and not string)
{
// Collection-valued members (WatermarkIds, Watermarks, GraphicsElementIds,
// GraphicsElements). The elementwise walk still delegates to each element's Equals,
// so it is value equality only because those elements are records
// (NamedIdResponseModel) or value types (the int id lists); a future element type that
// is neither would silently be compared by REFERENCE inside this loop. It is also order-sensitive, which is
// correct for these ordered lists but would be wrong for an unordered type such as
// a dictionary-valued property.
actual.ShouldNotBeNull($"{property.Name} was null on the round-tripped item");
var actualSequence = (IEnumerable)actual;
actualSequence.Cast<object?>().ToList()
.ShouldBe(expectedSequence.Cast<object?>().ToList(), $"{property.Name} differs");
}
else
{
actual.ShouldBe(expected, $"{property.Name} differs");
}
compared++;
}
// Anti-vacuity, as a PIN rather than a floor. A `>=` floor lets properties vanish silently,
// which is the one-sided version of the both-directions rule this test is meant to embody.
// Comparing against the reflected count minus exemptions would be tautological — both sides
// come from the same reflection — so the expected number is written down and must be
// bumped deliberately in the same change that adds or removes a DTO field.
const int expectedComparedProperties = 55;
compared.ShouldBe(
expectedComparedProperties,
$"{compared} properties were compared, expected {expectedComparedProperties}; update "
+ "this pin in the same change that alters ScheduleItemResponseModel's field list");
}
private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
{
await using TvContext context = _db.CreateContext();
context.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
context.Collections.Add(new Collection { Id = 2, Name = "Prime Collection" });
context.SmartCollections.Add(new SmartCollection { Id = 1, Name = "Smart Picks", Query = "*" });
context.RerunCollections.Add(new RerunCollection
{
Id = 1,
Name = "Rerun Bin",
CollectionType = CollectionType.Collection
});
context.PlaylistGroups.Add(new PlaylistGroup { Id = 9, Name = "Group" });
context.Playlists.Add(new Playlist { Id = 1, PlaylistGroupId = 9, Name = "My Playlist" });
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
context.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
context.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
AddFiller(context, 1, "PreRoll", FillerKind.PreRoll);
AddFiller(context, 2, "MidRoll", FillerKind.MidRoll);
AddFiller(context, 3, "PostRoll", FillerKind.PostRoll);
AddFiller(context, 4, "Tail", FillerKind.Tail);
AddFiller(context, 5, "Fallback", FillerKind.Fallback);
var schedule = new ProgramSchedule
{
Name = "Round Trip",
ShuffleScheduleItems = shuffleScheduleItems,
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
context.ProgramSchedules.Add(schedule);
await context.SaveChangesAsync();
return schedule.Id;
}
private static void AddFiller(TvContext context, int id, string name, FillerKind kind) =>
context.FillerPresets.Add(new FillerPreset
{
Id = id,
Name = name,
FillerKind = kind,
FillerMode = FillerMode.Count,
Count = 1,
CollectionType = CollectionType.Collection
});
}