+79
-5
@@ -38,12 +38,56 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
ProgramSchedule programSchedule,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.RemoveRange(programSchedule.Items);
|
||||
|
||||
// reset index starting with zero
|
||||
programSchedule.Items = [];
|
||||
// Positional in-place reconcile (rather than delete-and-reinsert): a schedule item owns the
|
||||
// persisted fill-group/shuffle enumerator state via PlayoutScheduleItemFillGroupIndex, whose
|
||||
// ProgramScheduleItemId FK is OnDelete(Cascade). Removing and re-inserting every item — as the
|
||||
// original handler did on every save, including a no-op PUT-back — cascade-deleted that state for
|
||||
// all playouts using the schedule (#252). Reusing the existing item row for the same-typed slot
|
||||
// keeps its id, so the cascade never fires and progression survives. The request DTO carries no
|
||||
// stable item id, so position is the only key available here; true content-aware stable identity
|
||||
// is deferred to the shared concurrency/round-trip contract in #253.
|
||||
var orderedItems = request.Items.OrderBy(i => i.Index).ToList();
|
||||
for (var i = 0; i < orderedItems.Count; i++)
|
||||
List<ProgramScheduleItem> existingItems = programSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
|
||||
// load the watermark/graphics join rows for the existing items so they can be rebuilt in place
|
||||
await dbContext.Entry(programSchedule)
|
||||
.Collection(ps => ps.Items)
|
||||
.Query()
|
||||
.Include(i => i.ProgramScheduleItemWatermarks)
|
||||
.Include(i => i.ProgramScheduleItemGraphicsElements)
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
int commonCount = Math.Min(existingItems.Count, orderedItems.Count);
|
||||
for (var i = 0; i < commonCount; i++)
|
||||
{
|
||||
ProgramScheduleItem existing = existingItems[i];
|
||||
ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]);
|
||||
if (existing.GetType() == built.GetType())
|
||||
{
|
||||
// same TPT subtype: copy all scalar values in place (BuildItem is the single source of
|
||||
// item construction, so no field is silently dropped) and rebuild the join rows, keeping
|
||||
// the item's id — and with it the fill-group index that would otherwise cascade away.
|
||||
built.Id = existing.Id;
|
||||
dbContext.Entry(existing).CurrentValues.SetValues(built);
|
||||
RebuildChildren(existing, orderedItems[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// EF can't change a TPT row's type in place; this slot must be replaced (its fill-group
|
||||
// index resets, which is acceptable — the item fundamentally changed).
|
||||
dbContext.Remove(existing);
|
||||
programSchedule.Items.Add(built);
|
||||
}
|
||||
}
|
||||
|
||||
// remove surplus existing items
|
||||
for (int i = commonCount; i < existingItems.Count; i++)
|
||||
{
|
||||
dbContext.Remove(existingItems[i]);
|
||||
}
|
||||
|
||||
// add surplus incoming items
|
||||
for (int i = commonCount; i < orderedItems.Count; i++)
|
||||
{
|
||||
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
|
||||
}
|
||||
@@ -68,6 +112,36 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
return persisted.Map(ProjectToViewModel).ToList();
|
||||
}
|
||||
|
||||
// Rebuild the watermark/graphics join rows for an item updated in place. Clearing severs the required
|
||||
// relationship so EF deletes the orphaned join rows (same net effect the delete-and-reinsert path had),
|
||||
// then re-add from the request. Only the parent item row is preserved — the join rows carry no state.
|
||||
private static void RebuildChildren(ProgramScheduleItem item, ReplaceProgramScheduleItem request)
|
||||
{
|
||||
item.ProgramScheduleItemWatermarks ??= [];
|
||||
item.ProgramScheduleItemWatermarks.Clear();
|
||||
foreach (int watermarkId in request.WatermarkIds)
|
||||
{
|
||||
item.ProgramScheduleItemWatermarks.Add(
|
||||
new ProgramScheduleItemWatermark
|
||||
{
|
||||
ProgramScheduleItem = item,
|
||||
WatermarkId = watermarkId
|
||||
});
|
||||
}
|
||||
|
||||
item.ProgramScheduleItemGraphicsElements ??= [];
|
||||
item.ProgramScheduleItemGraphicsElements.Clear();
|
||||
foreach (int graphicsElementId in request.GraphicsElementIds)
|
||||
{
|
||||
item.ProgramScheduleItemGraphicsElements.Add(
|
||||
new ProgramScheduleItemGraphicsElement
|
||||
{
|
||||
ProgramScheduleItem = item,
|
||||
GraphicsElementId = graphicsElementId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
ReplaceProgramScheduleItems request,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
public class ReplaceDecoTemplateItemsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
: IRequestHandler<ReplaceDecoTemplateItems, Either<BaseError, List<DecoTemplateItemViewModel>>>
|
||||
{
|
||||
public async Task<Either<BaseError, List<DecoTemplateItemViewModel>>> Handle(
|
||||
@@ -18,7 +23,7 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
}
|
||||
|
||||
private static async Task<List<DecoTemplateItemViewModel>> Persist(
|
||||
private async Task<List<DecoTemplateItemViewModel>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplaceDecoTemplateItems request,
|
||||
DecoTemplate decoTemplate,
|
||||
@@ -33,11 +38,23 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// TODO: refresh any playouts that use this schedule
|
||||
// foreach (Playout playout in programSchedule.Playouts)
|
||||
// {
|
||||
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
|
||||
// }
|
||||
// Deco/break/default-filler content is only (re)applied during a Reset build (a Continue keeps the
|
||||
// frozen DecoDefault filler items), and BlockKey change-detection has no deco dimension, so nothing
|
||||
// self-heals a deco-template edit — the editor returned 200 but built filler stayed stale until a
|
||||
// manual Reset (#251). Enqueue a Reset for every playout that references this deco template. This
|
||||
// whole post-commit invalidation runs with CancellationToken.None (audit #22 policy): once the edit
|
||||
// is committed, a late request cancellation must not be able to abort the affected-playout query OR
|
||||
// the enqueue and leave content stale.
|
||||
List<int> playoutIds = await dbContext.PlayoutTemplates
|
||||
.Where(pt => pt.DecoTemplateId == decoTemplate.Id)
|
||||
.Select(pt => pt.PlayoutId)
|
||||
.Distinct()
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
foreach (int playoutId in playoutIds)
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
|
||||
}
|
||||
|
||||
await dbContext.Entry(decoTemplate)
|
||||
.Collection(t => t.Items)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
public class UpdateDecoHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
: IRequestHandler<UpdateDeco, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateDeco request, CancellationToken cancellationToken)
|
||||
@@ -17,7 +22,7 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyUpdateRequest(
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Deco existing,
|
||||
UpdateDeco request,
|
||||
@@ -177,6 +182,34 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Deco content (break/default filler/etc.) is only (re)applied during a Reset build, and BlockKey
|
||||
// change-detection has no deco dimension, so editing a deco left already-built filler/break content
|
||||
// stale until a manual Reset (#251). Enqueue a Reset for every playout that references this deco —
|
||||
// directly (Playout.DecoId) or through a deco template that includes it. This whole post-commit
|
||||
// invalidation runs with CancellationToken.None (audit #22 policy): once the edit is committed, a
|
||||
// late request cancellation must not be able to abort either the affected-playout queries OR the
|
||||
// enqueue and leave content stale — the entire side effect has to complete.
|
||||
List<int> directPlayoutIds = await dbContext.Playouts
|
||||
.Where(p => p.DecoId == request.DecoId)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
List<int> decoTemplateIds = await dbContext.DecoTemplateItems
|
||||
.Where(i => i.DecoId == request.DecoId)
|
||||
.Select(i => i.DecoTemplateId)
|
||||
.Distinct()
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
List<int> templatePlayoutIds = await dbContext.PlayoutTemplates
|
||||
.Where(pt => pt.DecoTemplateId.HasValue && decoTemplateIds.Contains(pt.DecoTemplateId.Value))
|
||||
.Select(pt => pt.PlayoutId)
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
foreach (int playoutId in directPlayoutIds.Concat(templatePlayoutIds).Distinct())
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class UpdateDefaultDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
public class UpdateDefaultDecoHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
: IRequestHandler<UpdateDefaultDeco, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateDefaultDeco request, CancellationToken cancellationToken)
|
||||
@@ -13,10 +18,23 @@ public class UpdateDefaultDecoHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
await dbContext.Playouts
|
||||
int updated = await dbContext.Playouts
|
||||
.Where(p => p.Id == request.PlayoutId)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DecoId, p => request.DecoId), cancellationToken);
|
||||
|
||||
// Changing a playout's default deco only takes effect on a Reset build (deco content is applied
|
||||
// during Reset, and BlockKey has no deco dimension to self-heal) — same stale-until-Reset class
|
||||
// as #251. Enqueue a Reset for the affected playout, but only if a row was actually updated: a
|
||||
// request for a nonexistent playout must not queue a build for an id that isn't there (keeps the
|
||||
// enqueued set equal to the affected set). CancellationToken.None so the rebuild isn't dropped
|
||||
// after the assignment has already been persisted.
|
||||
if (updated > 0)
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Reset),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for #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 previously enqueued nothing, so filler/break content stayed stale indefinitely. These tests
|
||||
/// assert the handlers now enqueue a BuildPlayout(Reset) for exactly the affected playouts.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class DecoInvalidationTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private Channel<IBackgroundServiceRequest> _channel = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_channel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private List<BuildPlayout> DrainResetEnqueues()
|
||||
{
|
||||
var result = new List<BuildPlayout>();
|
||||
while (_channel.Reader.TryRead(out IBackgroundServiceRequest? req))
|
||||
{
|
||||
if (req is BuildPlayout bp)
|
||||
{
|
||||
result.Add(bp);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceDecoTemplateItems_Should_Reset_Playouts_Using_That_Deco_Template()
|
||||
{
|
||||
await using (TvContext ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Add(new Deco { Id = 1, DecoGroupId = 1, Name = "D" });
|
||||
ctx.Add(new DecoTemplate { Id = 1, DecoTemplateGroupId = 1, Name = "T", Items = [] });
|
||||
ctx.Add(new Playout { Id = 100, ChannelId = 1, Items = [] });
|
||||
ctx.Add(new Playout { Id = 200, ChannelId = 1, Items = [] });
|
||||
ctx.Add(MakePlayoutTemplate(playoutId: 100, decoTemplateId: 1));
|
||||
ctx.Add(MakePlayoutTemplate(playoutId: 200, decoTemplateId: null)); // control: no deco template
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new ReplaceDecoTemplateItemsHandler(_db.Factory, _channel.Writer);
|
||||
Either<BaseError, List<DecoTemplateItemViewModel>> result = await handler.Handle(
|
||||
new ReplaceDecoTemplateItems(
|
||||
DecoTemplateId: 1,
|
||||
DecoTemplateGroupId: 1,
|
||||
Name: "T",
|
||||
Items: [new ReplaceDecoTemplateItem(DecoId: 1, StartTime: TimeSpan.Zero, EndTime: TimeSpan.FromHours(1))]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
List<BuildPlayout> reset = DrainResetEnqueues();
|
||||
reset.Select(bp => bp.PlayoutId).ShouldBe([100]);
|
||||
reset.ShouldAllBe(bp => bp.Mode == PlayoutBuildMode.Reset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateDeco_Should_Reset_Playouts_Referencing_The_Deco_Directly_And_By_Template()
|
||||
{
|
||||
await using (TvContext ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Add(new Deco
|
||||
{
|
||||
Id = 1,
|
||||
DecoGroupId = 1,
|
||||
Name = "D",
|
||||
BreakContent = [],
|
||||
DecoWatermarks = [],
|
||||
DecoGraphicsElements = []
|
||||
});
|
||||
|
||||
// direct reference
|
||||
ctx.Add(new Playout { Id = 100, ChannelId = 1, DecoId = 1, Items = [] });
|
||||
|
||||
// reference via a deco template that includes this deco
|
||||
ctx.Add(new DecoTemplate
|
||||
{
|
||||
Id = 1,
|
||||
DecoTemplateGroupId = 1,
|
||||
Name = "T",
|
||||
Items = [new DecoTemplateItem { DecoTemplateId = 1, DecoId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromHours(1) }]
|
||||
});
|
||||
ctx.Add(new Playout { Id = 200, ChannelId = 1, Items = [] });
|
||||
ctx.Add(MakePlayoutTemplate(playoutId: 200, decoTemplateId: 1));
|
||||
|
||||
// control: unrelated playout
|
||||
ctx.Add(new Playout { Id = 300, ChannelId = 1, Items = [] });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel.Writer);
|
||||
Either<BaseError, Unit> result = await handler.Handle(MakeUpdateDeco(decoId: 1), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
List<BuildPlayout> reset = DrainResetEnqueues();
|
||||
reset.Select(bp => bp.PlayoutId).OrderBy(id => id).ShouldBe([100, 200]);
|
||||
reset.ShouldAllBe(bp => bp.Mode == PlayoutBuildMode.Reset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateDefaultDeco_Should_Reset_The_Affected_Playout()
|
||||
{
|
||||
await using (TvContext ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Add(new Playout { Id = 100, ChannelId = 1, Items = [] });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new UpdateDefaultDecoHandler(_db.Factory, _channel.Writer);
|
||||
Option<BaseError> result = await handler.Handle(new UpdateDefaultDeco(PlayoutId: 100, DecoId: 1), CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
|
||||
List<BuildPlayout> reset = DrainResetEnqueues();
|
||||
reset.Select(bp => bp.PlayoutId).ShouldBe([100]);
|
||||
reset.ShouldAllBe(bp => bp.Mode == PlayoutBuildMode.Reset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateDefaultDeco_Should_Not_Enqueue_For_A_Nonexistent_Playout()
|
||||
{
|
||||
// no playout seeded — ExecuteUpdateAsync matches 0 rows
|
||||
var handler = new UpdateDefaultDecoHandler(_db.Factory, _channel.Writer);
|
||||
Option<BaseError> result = await handler.Handle(new UpdateDefaultDeco(PlayoutId: 999, DecoId: 1), CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
DrainResetEnqueues().ShouldBeEmpty(); // enqueued set must equal the affected (empty) set
|
||||
}
|
||||
|
||||
private static PlayoutTemplate MakePlayoutTemplate(int playoutId, int? decoTemplateId) =>
|
||||
new()
|
||||
{
|
||||
PlayoutId = playoutId,
|
||||
TemplateId = 1,
|
||||
DecoTemplateId = decoTemplateId,
|
||||
Index = 0,
|
||||
DaysOfWeek = [],
|
||||
DaysOfMonth = [],
|
||||
MonthsOfYear = []
|
||||
};
|
||||
|
||||
private static UpdateDeco MakeUpdateDeco(int decoId) =>
|
||||
new(
|
||||
decoId,
|
||||
DecoGroupId: 1,
|
||||
Name: "D",
|
||||
WatermarkMode: DecoMode.Inherit,
|
||||
WatermarkIds: [],
|
||||
UseWatermarkDuringFiller: false,
|
||||
GraphicsElementsMode: DecoMode.Inherit,
|
||||
GraphicsElementIds: [],
|
||||
UseGraphicsElementsDuringFiller: false,
|
||||
BreakContentMode: DecoMode.Inherit,
|
||||
BreakContent: [],
|
||||
DefaultFillerMode: DecoMode.Inherit,
|
||||
DefaultFillerCollectionType: CollectionType.Collection,
|
||||
DefaultFillerCollectionId: null,
|
||||
DefaultFillerMediaItemId: null,
|
||||
DefaultFillerMultiCollectionId: null,
|
||||
DefaultFillerSmartCollectionId: null,
|
||||
DefaultFillerTrimToFit: false,
|
||||
DeadAirFallbackMode: DecoMode.Inherit,
|
||||
DeadAirFallbackCollectionType: CollectionType.Collection,
|
||||
DeadAirFallbackCollectionId: null,
|
||||
DeadAirFallbackMediaItemId: null,
|
||||
DeadAirFallbackMultiCollectionId: null,
|
||||
DeadAirFallbackSmartCollectionId: null);
|
||||
}
|
||||
Reference in New Issue
Block a user