fix(71): reseed all playout kinds on reshuffle (erase-history + rebuild); docs + minors
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m24s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 6m58s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 11m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16m11s

Reset only reseeds Playout.Seed for Classic playouts (PlayoutBuilder); Block/
Sequential/Scripted rebuild deterministically from the existing seed, so
reshuffle was a silent no-op for 3 of the 4 supported kinds (C1). Fix:
ReshufflePlayoutHandler now sends ErasePlayoutHistory (reseed + clear anchors/
history, the only primitive that reseeds all four kinds) before enqueueing
BuildPlayout(Reset).

Also: correct docs/decisions.md's false "Reset already reseeds..." claim,
fix the SPA reshuffle test mock to return 202 (matches the real endpoint),
and gate the seed-help text to the resettable kinds (was showing even for
ExternalJson/None where no Reshuffle button exists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:47:18 +02:00
co-authored by Claude Opus 4.8
parent 6a4d81f862
commit 81684411bd
5 changed files with 90 additions and 13 deletions
@@ -1,4 +1,5 @@
using System.Threading.Channels;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -7,6 +8,7 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class ReshufflePlayoutHandler(
IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReshufflePlayout>
@@ -25,6 +27,11 @@ public class ReshufflePlayoutHandler(
foreach (Playout playout in maybePlayout)
{
// Roll a new play order. BuildPlayout(Reset) only reseeds Playout.Seed for CLASSIC playouts
// (PlayoutBuilder); Block/Sequential/Scripted rebuild deterministically from the existing seed.
// ErasePlayoutHistory is the one primitive that reseeds + clears the derived per-collection
// enumerator anchors for ALL four kinds — run it first, then rebuild from scratch.
await mediator.Send(new ErasePlayoutHistory(playout.Id), cancellationToken);
await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
@@ -4,10 +4,15 @@ using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;
@@ -19,23 +24,30 @@ public class ReshufflePlayoutHandlerTests
{
private InMemoryTvContext _db = null!;
private Channel<IBackgroundServiceRequest> _worker = null!;
private IMediator _mediator = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
_mediator = Substitute.For<IMediator>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ReshufflePlayoutHandler CreateHandler() => new(_worker.Writer, _db.Factory);
private ReshufflePlayoutHandler CreateHandler() => new(_mediator, _worker.Writer, _db.Factory);
private async Task<int> SeedPlayout(PlayoutScheduleKind kind)
private async Task<int> SeedPlayout(PlayoutScheduleKind kind, int? seed = null)
{
await using TvContext context = _db.CreateContext();
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
if (seed.HasValue)
{
playout.Seed = seed.Value;
}
context.Playouts.Add(playout);
await context.SaveChangesAsync();
return playout.Id;
@@ -45,12 +57,17 @@ public class ReshufflePlayoutHandlerTests
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task Handle_Should_Enqueue_Reset_Build_For_Supported_Kind(PlayoutScheduleKind kind)
public async Task Handle_Should_Erase_History_Then_Enqueue_Reset_Build_For_Supported_Kind(
PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutHistory>(e => e.PlayoutId == id),
Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var build = request.ShouldBeOfType<BuildPlayout>();
build.PlayoutId.ShouldBe(id);
@@ -60,12 +77,56 @@ public class ReshufflePlayoutHandlerTests
[TestCase(PlayoutScheduleKind.ExternalJson)]
[TestCase(PlayoutScheduleKind.None)]
public async Task Handle_Should_Not_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
public async Task Handle_Should_Not_Erase_History_Or_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task Handle_Should_Actually_Reseed_Block_Playout_Via_ErasePlayoutHistory()
{
// This is the C1 regression test: BuildPlayout(Reset) alone only reseeds Playout.Seed for
// Classic playouts (PlayoutBuilder). For Block/Sequential/Scripted it is a no-op reshuffle
// unless the handler routes through ErasePlayoutHistory first. Wire the substituted
// IMediator to actually invoke the real ErasePlayoutHistoryHandler against the in-memory DB
// so the reseed side effect is genuinely exercised, not merely asserted-as-called.
const int originalSeed = 12345;
int id = await SeedPlayout(PlayoutScheduleKind.Block, originalSeed);
// seed a PlayoutHistory row so we can also assert the deterministic "cleared" side effect
// (avoids relying solely on new Random().Next() != originalSeed, which is a ~1-in-2^31 flake)
await using (TvContext seedContext = _db.CreateContext())
{
seedContext.PlayoutHistory.Add(
new PlayoutHistory
{
PlayoutId = id,
Key = "test-collection",
When = DateTime.UtcNow,
Finish = DateTime.UtcNow
});
await seedContext.SaveChangesAsync();
}
_mediator.Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>())
.Returns(callInfo => new ErasePlayoutHistoryHandler(_db.Factory).Handle(
(ErasePlayoutHistory)callInfo[0],
(CancellationToken)callInfo[1]));
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await using TvContext context = _db.CreateContext();
Playout playout = await context.Playouts.SingleAsync(p => p.Id == id);
playout.Seed.ShouldNotBe(originalSeed);
List<PlayoutHistory> remainingHistory = await context.PlayoutHistory
.Where(h => h.PlayoutId == id)
.ToListAsync();
remainingHistory.ShouldBeEmpty();
}
}
+9 -5
View File
@@ -1048,10 +1048,14 @@ channel; a name collision with an existing SmartCollection surfaces as a per-cha
shuffled order across rebuilds. #71's real gap was a **user-triggered per-playout reshuffle** (a Classic
playout's seed is otherwise only reseeded by a full Reset, and `reset-all` uses `Refresh` for Classic, so
it never reseeds Classic) plus visibility.
- `POST /api/v1/playouts/{id}/reshuffle` enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` — Reset already
reseeds + clears anchors/rerun-history + rebuilds. Named `/reshuffle` (not `/reset`) to (a) match user
intent and (b) avoid the "reset-one reseeds Classic while reset-all refreshes Classic" naming clash. It is
deliberately more aggressive than `reset-all` for Classic: an explicit single-channel action rolls a new
order; the bulk action stays non-disruptive.
- `POST /api/v1/playouts/{id}/reshuffle` first runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears
anchors/rerun-history) and then enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` to rebuild. `Reset`
alone only reseeds the seed for **Classic** playouts (`PlayoutBuilder`); Block/Sequential/Scripted rebuild
deterministically from the existing seed, so `ErasePlayoutHistory` is the one primitive that reseeds +
clears the derived per-collection enumerator state for all four resettable kinds — reshuffle runs it
first so the reshuffle is never a no-op for non-Classic playouts. Named `/reshuffle` (not `/reset`) to (a)
match user intent and (b) avoid the "reset-one reseeds Classic while reset-all refreshes Classic" naming
clash. It is deliberately more aggressive than `reset-all` for Classic: an explicit single-channel action
rolls a new order; the bulk action stays non-disruptive.
- `Playout.Seed` is surfaced on the playout list + detail DTOs so the SPA can show it — its purpose is
**visible confirmation** (the seed changes after a reshuffle).
+1 -1
View File
@@ -191,7 +191,7 @@ function mockApi(options: MockOptions = {}): void {
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
return Promise.resolve(new Response(null, { status: 202 }));
}
if (/^\/api\/v1\/playouts\/\d+\/deco$/.test(path)) {
+8 -3
View File
@@ -647,9 +647,14 @@ export function PlayoutsScreen() {
<Input disabled label="Rebuild" value={formatDailyRebuild(playout?.dailyRebuildTime ?? selectedSummary.dailyRebuildTime)} />
<Input disabled label="Seed" value={String(playout?.seed ?? selectedSummary.seed)} />
</div>
<p className="ctv-playout-seed-help">
The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order.
</p>
{(selectedSummary.scheduleKind === 'Classic' ||
selectedSummary.scheduleKind === 'Block' ||
selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && (
<p className="ctv-playout-seed-help">
The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order.
</p>
)}
{selectedSummary.scheduleKind === 'Classic' && (
<div className="ctv-playout-detail-actions">
<Button