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
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:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user