Files
ersatztv/ErsatzTV.Tests/Application/ProgramSchedules/ReplaceProgramScheduleItemsReconcileTests.cs
T
timothyandClaude Opus 4.8 64473b136d
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(scheduling): stop silently resetting fill-group state + invalidate deco edits (#251, #252)
#252: ReplaceProgramScheduleItems deleted and re-inserted every item on every
save (even a no-op PUT-back), and PlayoutScheduleItemFillGroupIndex.ProgramScheduleItemId
is OnDelete(Cascade) — so every schedule save silently wiped persisted
fill-group/shuffle enumerator progression for all playouts using the schedule.
Switch to a positional in-place reconcile: for a same-typed slot, copy scalars via
CurrentValues.SetValues (BuildItem stays the single source of item construction, so
no field is dropped) and rebuild the watermark/graphics join rows, keeping the item
id — and with it the fill-group index. Subtype change / surplus falls back to
delete+insert for that slot only. The request DTO carries no stable item id, so
position is the only key here; true content-aware stable identity is deferred to the
shared concurrency/round-trip contract in #253.

#251: deco / deco-template CONTENT edits (and default-deco assignment) only take
effect on a playout Reset build — deco/break/default-filler content is applied during
Reset, a Continue keeps the frozen filler items, and BlockKey change-detection has no
deco dimension to self-heal. The editors enqueued nothing (a commented-out TODO in
ReplaceDecoTemplateItemsHandler), so filler/break content stayed stale indefinitely
until a manual Reset. Enqueue BuildPlayout(Reset) for exactly the affected playouts:
- ReplaceDecoTemplateItemsHandler: playouts via PlayoutTemplate.DecoTemplateId
- UpdateDecoHandler: playouts via Playout.DecoId and via deco-template items
- UpdateDefaultDecoHandler: the reassigned playout (adjacent same-class fix)
Post-commit enqueues use CancellationToken.None (audit #22 policy).

Tests: DecoInvalidationTests + ReplaceProgramScheduleItemsReconcileTests, each proven
non-vacuous against a negative control (inverted the primitive, verified 0 CS errors so
the --no-build run used a fresh dll). Full ErsatzTV.Tests suite green (1067).

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

293 lines
12 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
/// <summary>
/// Regression tests for #252: the ReplaceProgramScheduleItems write path used to delete every existing
/// item and re-insert with fresh ids, which cascade-deleted <see cref="PlayoutScheduleItemFillGroupIndex" />
/// (FK OnDelete(Cascade)) — silently resetting persisted fill-group/shuffle progression on every save,
/// even a no-op PUT-back. The handler now reconciles in place, keeping the item id (and its fill-group
/// index) for a same-typed slot. The primary assertion is on the mechanism — item ids stay stable across
/// a no-op save — because the in-memory harness runs with foreign_keys=OFF, so an assertion on the cascade
/// alone would be FK-pragma dependent.
/// </summary>
[TestFixture]
public class ReplaceProgramScheduleItemsReconcileTests
{
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 NoOp_Replace_Should_Preserve_Item_Ids_And_FillGroup_State()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var items = new List<ReplaceProgramScheduleItem>
{
MakeItem(0, PlayoutMode.Multiple, "group") with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups },
MakeItem(1, PlayoutMode.One, "news")
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None))
.IsRight.ShouldBeTrue();
List<int> originalIds;
int fillGroupItemId;
await using (TvContext ctx = _db.CreateContext())
{
List<ProgramScheduleItem> persisted = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.OrderBy(i => i.Index)
.ToListAsync();
originalIds = persisted.Select(i => i.Id).ToList();
fillGroupItemId = persisted[0].Id;
// persisted fill-group enumerator state pointing at item 0 (foreign_keys=OFF, so no Playout row needed)
ctx.Add(new PlayoutScheduleItemFillGroupIndex
{
PlayoutId = 1,
ProgramScheduleItemId = fillGroupItemId,
EnumeratorState = new CollectionEnumeratorState { Seed = 12345, Index = 7 }
});
await ctx.SaveChangesAsync();
}
// Act: PUT the identical items back — a no-op save.
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
List<int> afterIds = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.OrderBy(i => i.Index)
.Select(i => i.Id)
.ToListAsync();
afterIds.ShouldBe(originalIds);
PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.Include(x => x.EnumeratorState)
.SingleAsync();
fillGroup.ProgramScheduleItemId.ShouldBe(fillGroupItemId);
fillGroup.EnumeratorState.Seed.ShouldBe(12345);
fillGroup.EnumeratorState.Index.ShouldBe(7);
}
}
[Test]
public async Task Reorder_Should_Preserve_Untouched_Item_Id_And_FillGroup_State()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var items = new List<ReplaceProgramScheduleItem>
{
MakeItem(0, PlayoutMode.One, "a"),
MakeItem(1, PlayoutMode.One, "b"),
MakeItem(2, PlayoutMode.Multiple, "c") with { FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups }
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None))
.IsRight.ShouldBeTrue();
List<int> originalIds;
int untouchedItemId;
await using (TvContext ctx = _db.CreateContext())
{
List<ProgramScheduleItem> persisted = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.OrderBy(i => i.Index)
.ToListAsync();
originalIds = persisted.Select(i => i.Id).OrderBy(id => id).ToList();
untouchedItemId = persisted[2].Id; // the item at index 2 ("c") is not reordered below
ctx.Add(new PlayoutScheduleItemFillGroupIndex
{
PlayoutId = 1,
ProgramScheduleItemId = untouchedItemId,
EnumeratorState = new CollectionEnumeratorState { Seed = 99, Index = 4 }
});
await ctx.SaveChangesAsync();
}
// Act: swap the first two items; the third ("c") is unchanged and stays at index 2.
var reordered = new List<ReplaceProgramScheduleItem>
{
items[1] with { Index = 0 },
items[0] with { Index = 1 },
items[2]
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, reordered), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
// no item row was cascade-deleted: the full id set is intact
List<int> afterIds = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.Select(i => i.Id)
.OrderBy(id => id)
.ToListAsync();
afterIds.ShouldBe(originalIds);
// the untouched item kept its id and its fill-group state
PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.Include(x => x.EnumeratorState)
.SingleAsync();
fillGroup.ProgramScheduleItemId.ShouldBe(untouchedItemId);
fillGroup.EnumeratorState.Index.ShouldBe(4);
}
}
[Test]
public async Task InPlace_Update_Should_Still_Apply_Watermark_Edits()
{
int scheduleId = await SeedSchedule();
await using (TvContext ctx = _db.CreateContext())
{
// the response projection hydrates watermark navs, so the referenced rows must exist
ctx.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
ctx.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
await ctx.SaveChangesAsync();
}
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var initial = new List<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "a") with { WatermarkIds = [1] } };
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, initial), CancellationToken.None))
.IsRight.ShouldBeTrue();
int itemId;
await using (TvContext ctx = _db.CreateContext())
{
itemId = await ctx.ProgramScheduleItems.Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).SingleAsync();
}
// change only the watermark set; same slot, same subtype -> in-place update
var edited = new List<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "a") with { WatermarkIds = [2] } };
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, edited), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
ProgramScheduleItem persisted = await ctx.ProgramScheduleItems
.Include(i => i.ProgramScheduleItemWatermarks)
.Where(i => i.ProgramScheduleId == scheduleId)
.SingleAsync();
persisted.Id.ShouldBe(itemId); // in-place: id preserved
persisted.ProgramScheduleItemWatermarks.Select(w => w.WatermarkId).ShouldBe([2]); // edit applied, old removed
}
}
[Test]
public async Task Subtype_Change_Should_Replace_The_Item()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var initial = new List<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "a") };
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, initial), CancellationToken.None))
.IsRight.ShouldBeTrue();
int originalId;
await using (TvContext ctx = _db.CreateContext())
{
originalId = await ctx.ProgramScheduleItems.Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).SingleAsync();
}
// EF cannot change a TPH row's type in place, so a mode change replaces the item (new id).
var changed = new List<ReplaceProgramScheduleItem>
{
MakeItem(0, PlayoutMode.Duration, "a") with { PlayoutDuration = TimeSpan.FromMinutes(30), DiscardToFillAttempts = 0 }
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, changed), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
ProgramScheduleItem persisted = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.SingleAsync();
persisted.ShouldBeOfType<ProgramScheduleItemDuration>();
persisted.Id.ShouldNotBe(originalId);
}
}
private async Task<int> SeedSchedule()
{
await using TvContext ctx = _db.CreateContext();
var schedule = new ProgramSchedule
{
Name = "Reconcile",
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
ctx.ProgramSchedules.Add(schedule);
await ctx.SaveChangesAsync();
return schedule.Id;
}
private static ReplaceProgramScheduleItem MakeItem(int index, PlayoutMode mode, string searchQuery) =>
new(
index,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
mode,
CollectionType.SearchQuery,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: searchQuery,
SearchQuery: searchQuery,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode.None,
MultipleMode.Count,
MultipleCount: "1",
PlayoutDuration: null,
TailMode.None,
DiscardToFillAttempts: null,
CustomTitle: null,
GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
}