Files
ersatztv/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs
T
timothyandClaude Opus 4.8 162b334e5d test(#259): id-based reconcile matrix + docs (api-conventions §7c, decisions)
Add the id-based reconcile tests to ReplaceProgramScheduleItemsReconcileTests:
reorder moves state with the logical item (the non-vacuous core — proven to fail
under forced-positional), insert-in-middle, delete-unreferenced, unknown-id→422,
duplicate-id→422, and stale-version+unknown-id→412 (412 precedes 422, §7c). The
GET→map→PUT lossless round-trip now round-trips r.Id so it exercises id-mode.
Threads the new int? Id through all command/wire construction sites in tests.

Docs: api-conventions §7c (stable child identity + the deliberate #2-#5 positional
asymmetry) and a decisions.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:53:50 +02:00

453 lines
21 KiB
C#

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 (ignoring regenerated row ids).
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);
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
{
b.Index.ShouldBe(a.Index);
b.StartType.ShouldBe(a.StartType);
b.StartTime.ShouldBe(a.StartTime);
b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
b.PlayoutMode.ShouldBe(a.PlayoutMode);
b.CollectionType.ShouldBe(a.CollectionType);
b.CollectionId.ShouldBe(a.CollectionId);
b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
b.MediaItemId.ShouldBe(a.MediaItemId);
b.PlaylistId.ShouldBe(a.PlaylistId);
b.SearchTitle.ShouldBe(a.SearchTitle);
b.SearchQuery.ShouldBe(a.SearchQuery);
b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
b.MultipleMode.ShouldBe(a.MultipleMode);
b.MultipleCount.ShouldBe(a.MultipleCount);
b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
b.TailMode.ShouldBe(a.TailMode);
b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
b.CustomTitle.ShouldBe(a.CustomTitle);
b.GuideMode.ShouldBe(a.GuideMode);
b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
b.TailFillerId.ShouldBe(a.TailFillerId);
b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
b.WatermarkIds.ShouldBe(a.WatermarkIds);
b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
b.SubtitleMode.ShouldBe(a.SubtitleMode);
b.CollectionName.ShouldBe(a.CollectionName);
b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
b.PlaylistName.ShouldBe(a.PlaylistName);
b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
b.MediaItemName.ShouldBe(a.MediaItemName);
b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
b.TailFillerName.ShouldBe(a.TailFillerName);
b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
b.Name.ShouldBe(a.Name);
b.DurationEstimate.ShouldBe(a.DurationEstimate);
}
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
});
}