Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m55s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent Codex review of the fix diff surfaced two real findings the fork pass missed: - Medium: the #251 affected-playout QUERIES in ReplaceDecoTemplateItemsHandler and UpdateDecoHandler still ran on the request `cancellationToken`, so a cancellation landing after SaveChanges committed but before those queries executed would throw before the CancellationToken.None enqueue — the edit committed but no playout Reset, re-opening the stale-content bug in that window. Run the entire post-commit invalidation (queries + enqueue) on CancellationToken.None so the side effect can't be half-aborted once the data has changed. - Low: UpdateDefaultDecoHandler enqueued a Reset for request.PlayoutId even when ExecuteUpdateAsync matched 0 rows (nonexistent playout), creating a background build request for an id that isn't there. Guard the enqueue on rows-updated > 0 so the enqueued set equals the affected set. Added a regression test. Also corrected the ReplaceProgramScheduleItemsHandler comments: the schedule-item hierarchy is TPT (table-per-type), not TPH — the SetValues reconcile is safe either way (same-runtime-type guard; no discriminator to corrupt), Codex confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
7.6 KiB
C#
197 lines
7.6 KiB
C#
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);
|
|
}
|