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
#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>
293 lines
12 KiB
C#
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);
|
|
}
|