Merge pull request 'feat(71): per-playout reshuffle + shuffle-state surfacing' (#387) from feat/71-reshuffle-playout into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Merge pull request '#387' (feat(71): per-playout reshuffle + shuffle-state surfacing) from feat/71-reshuffle-playout into main fixes #71
This commit was merged in pull request #387.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReshufflePlayout(int PlayoutId) : IRequest;
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ReshufflePlayoutHandler(
|
||||
IMediator mediator,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ReshufflePlayout>
|
||||
{
|
||||
public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Block ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -65,7 +65,8 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -57,7 +57,8 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.CommandLine.Parsing;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
@@ -60,7 +60,8 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -65,7 +65,8 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
@@ -20,7 +20,8 @@ internal static class Mapper
|
||||
// the paged-playouts query does not eager-load Deco (the list response does not surface
|
||||
// the default deco); GetPlayoutById includes it for the detail response
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
|
||||
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
@@ -14,7 +14,8 @@ public record PlayoutNameViewModel(
|
||||
PlayoutBuildStatus BuildStatus,
|
||||
int? DecoId,
|
||||
string DecoName,
|
||||
int Version)
|
||||
int Version,
|
||||
int Seed)
|
||||
{
|
||||
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -31,6 +31,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
p.BuildStatus,
|
||||
p.DecoId,
|
||||
p.DecoId == null ? null : p.Deco.Name,
|
||||
p.Version));
|
||||
p.Version,
|
||||
p.Seed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ public record PlayoutListItemResponseModel(
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
bool IsLocked);
|
||||
bool IsLocked,
|
||||
int Seed);
|
||||
|
||||
@@ -15,7 +15,8 @@ public record PlayoutResponseModel(
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
int? DecoId,
|
||||
string? DecoName,
|
||||
bool IsLocked)
|
||||
bool IsLocked,
|
||||
int Seed)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
@@ -29,7 +30,8 @@ public record PlayoutResponseModel(
|
||||
PlayoutBuildStatusResponseModel? buildStatus,
|
||||
int? decoId,
|
||||
string? decoName,
|
||||
bool isLocked) =>
|
||||
bool isLocked,
|
||||
int seed) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
@@ -42,5 +44,6 @@ public record PlayoutResponseModel(
|
||||
buildStatus,
|
||||
decoId,
|
||||
decoName,
|
||||
isLocked);
|
||||
isLocked,
|
||||
seed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
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;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Playouts;
|
||||
|
||||
[TestFixture]
|
||||
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(_mediator, _worker.Writer, _db.Factory);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
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);
|
||||
build.Mode.ShouldBe(PlayoutBuildMode.Reset);
|
||||
_worker.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
[TestCase(PlayoutScheduleKind.None)]
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -484,6 +484,7 @@ public class ChannelControllerTests
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0);
|
||||
|
||||
private static ChannelDetailResponseModel MakeDetailModel(int id) =>
|
||||
|
||||
@@ -70,6 +70,7 @@ public class PlayoutControllerTests
|
||||
nameof(PlayoutController.EraseItemsAndHistory),
|
||||
"POST",
|
||||
"/api/v1/playouts/{id:int}/erase-items-and-history");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlayoutController.GetItemSchedulingContext),
|
||||
"GET",
|
||||
@@ -152,6 +153,30 @@ public class PlayoutControllerTests
|
||||
result.Page.Single().IsLocked.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Surface_Seed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
|
||||
|
||||
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>()
|
||||
.Value.ShouldBeOfType<PlayoutResponseModel>()
|
||||
.Seed.ShouldBe(4242);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Surface_Seed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
||||
|
||||
result.Page[0].Seed.ShouldBe(4242);
|
||||
}
|
||||
|
||||
// ----- Erase items / history -----
|
||||
|
||||
[Test]
|
||||
@@ -237,6 +262,62 @@ public class PlayoutControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Reshuffle -----
|
||||
|
||||
[Test]
|
||||
public async Task Reshuffle_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
[TestCase(PlayoutScheduleKind.None)]
|
||||
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Playout item scheduling context -----
|
||||
|
||||
[Test]
|
||||
@@ -1341,6 +1422,7 @@ public class PlayoutControllerTests
|
||||
new PlayoutBuildStatus(),
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0);
|
||||
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
|
||||
@@ -1361,7 +1443,8 @@ public class PlayoutControllerTests
|
||||
vm.BuildStatus.Message),
|
||||
vm.DecoId,
|
||||
vm.DecoName,
|
||||
isLocked);
|
||||
isLocked,
|
||||
vm.Seed);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
|
||||
@@ -683,6 +683,45 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reshuffle a playout")]
|
||||
[EndpointDescription(
|
||||
"Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " +
|
||||
"and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
|
||||
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
|
||||
{
|
||||
return BaseError.New(
|
||||
"[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts")
|
||||
.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
await mediator.Send(new ReshufflePlayout(id), cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Decode a playout item's scheduling context")]
|
||||
@@ -786,7 +825,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.DecoId,
|
||||
vm.DecoName,
|
||||
isLocked);
|
||||
isLocked,
|
||||
vm.Seed);
|
||||
|
||||
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
|
||||
new(
|
||||
@@ -835,7 +875,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.PlayoutMode,
|
||||
isLocked);
|
||||
isLocked,
|
||||
vm.Seed);
|
||||
|
||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||
buildStatus is null
|
||||
|
||||
@@ -14382,6 +14382,115 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/playouts/{id}/reshuffle": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Playouts"
|
||||
],
|
||||
"summary": "Reshuffle a playout",
|
||||
"description": "Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.",
|
||||
"operationId": "ReshufflePlayout",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "API key missing or invalid.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Request validation failed (model binding or FluentValidation).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{ }
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/playouts/items/{id}/scheduling-context": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -28256,7 +28365,8 @@
|
||||
"dailyRebuildTime",
|
||||
"buildStatus",
|
||||
"playoutMode",
|
||||
"isLocked"
|
||||
"isLocked",
|
||||
"seed"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -28298,6 +28408,10 @@
|
||||
},
|
||||
"isLocked": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -28323,7 +28437,8 @@
|
||||
"buildStatus",
|
||||
"decoId",
|
||||
"decoName",
|
||||
"isLocked"
|
||||
"isLocked",
|
||||
"seed"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -28384,6 +28499,10 @@
|
||||
},
|
||||
"isLocked": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -146,7 +146,7 @@ SPA-ready are preserved below for the record.
|
||||
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
|
||||
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
|
||||
> | Playout creation + alternate-schedules | PARITY-OK | — |
|
||||
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
|
||||
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215; per-playout **Reshuffle** action added alongside Reset/Erase/Delete — `POST /api/v1/playouts/{id}/reshuffle` reseeds + rebuilds via `PlayoutBuildMode.Reset`, 2026-07-16 #71) | — |
|
||||
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
|
||||
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
|
||||
> | Channel editor | PARITY-OK (external logo URL mutual-exclusion, bare-create defaults, enumerated pickers — 2026-07-11) | #212 |
|
||||
|
||||
@@ -82,6 +82,7 @@ in-file entries.
|
||||
- [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299)
|
||||
- [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340)
|
||||
- [2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)](#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69)
|
||||
- [2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)](#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71)
|
||||
|
||||
---
|
||||
|
||||
@@ -1040,3 +1041,21 @@ name already exists is flagged and de-selected by default, and existing channels
|
||||
Bulk create loops the #63 `CreateChannelFromLineup` primitive via `ISender` and returns a per-channel
|
||||
Created/Skipped/Failed outcome. Known MVP limitation: the generated SmartCollection is named after the
|
||||
channel; a name collision with an existing SmartCollection surfaces as a per-channel Failed outcome.
|
||||
|
||||
## 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)
|
||||
|
||||
- The shuffle seed (`Playout.Seed`) + per-collection `CollectionEnumeratorState` already persist a stable
|
||||
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` 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).
|
||||
|
||||
@@ -59,6 +59,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **Sequential playout** | Driven by a YAML file (`Playout.ScheduleFile`); validated via the Schedule Validator screen. | `Playout.ScheduleFile` | `/app/troubleshooting/yaml` (validate only; file itself is server-side) |
|
||||
| **Scripted playout** | `PlayoutScheduleKind.Scripted`; backed by `ScriptedScheduleController`. | — | — |
|
||||
| **External-JSON playout** | `PlayoutScheduleKind.ExternalJson = 20`; JSON-driven, same shape idea as Sequential but JSON instead of YAML. | `Playout.ScheduleFile` | — |
|
||||
| **Reshuffle** | `Playout.Seed` (the master shuffle seed) is surfaced on the playout list + detail API so the SPA can show it; a per-playout **Reshuffle** action reseeds and rebuilds via `POST /api/v1/playouts/{id}/reshuffle` (enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)`), distinct from `reset-all`'s `Refresh` mode for Classic. | `Playout.Seed` | `/app/playouts` |
|
||||
| **Deco** | Per-playout "decoration": one of 4 independently-modal sections — watermark, graphics elements, default filler, dead-air fallback — plus break content. Each mode section is `Inherit`/`Disable`/`Override`/`Merge` (`DecoMode`). Can attach directly to a Block playout via `Playout.DecoId`. | `Deco`, `DecoGroup`, `DecoBreakContent` | `/app/decos` |
|
||||
| **DecoTemplate** | Time-of-day (`DecoTemplateItem.StartTime`/`EndTime`) calendar of `Deco`s, assigned to a playout via `PlayoutTemplate.DecoTemplateId` (same row as the Block-template assignment — one `PlayoutTemplate` entry carries both a `Template` and an optional `DecoTemplate`). | `DecoTemplate`, `DecoTemplateItem`, `DecoTemplateGroup` | `/app/deco-templates` |
|
||||
| **Default deco vs deco templates** | `Playout.DecoId` = one static deco for the whole playout; `PlayoutTemplate.DecoTemplateId` = a time-varying deco schedule. Both are optional and independent. | `Playout`, `PlayoutTemplate` | `/app/playouts/{id}/templates` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
163 endpoints, 246 operations.
|
||||
164 endpoints, 247 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -286,6 +286,7 @@
|
||||
| POST | `/api/v1/playouts/{id}/erase-items` | ErasePlayoutItems | Erase a playout's items |
|
||||
| POST | `/api/v1/playouts/{id}/erase-items-and-history` | ErasePlayoutItemsAndHistory | Erase a playout's items and history |
|
||||
| GET | `/api/v1/playouts/{id}/items` | GetPlayoutItems | Get upcoming items (and unscheduled gaps) for a playout |
|
||||
| POST | `/api/v1/playouts/{id}/reshuffle` | ReshufflePlayout | Reshuffle a playout |
|
||||
| GET | `/api/v1/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates |
|
||||
| PUT | `/api/v1/playouts/{id}/templates` | PlayoutReplaceTemplates | Replace a block playout's templates |
|
||||
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
# Per-playout Reshuffle + shuffle-state surfacing (#71) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a per-playout "Reshuffle" action (rolls a new random play order + rebuilds) and surface the play-order seed in the SPA.
|
||||
|
||||
**Architecture:** A new `POST /api/v1/playouts/{id}/reshuffle` endpoint delegates to a fire-and-forget MediatR command that enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` — the existing `Reset` build mode already reseeds `Playout.Seed` and rebuilds. The seed is projected onto the playout list/detail DTOs so the SPA can display it (visible confirmation the reshuffle worked). SPA adds a "Reshuffle" button (mirroring the existing erase/reset per-playout actions) plus a seed display line.
|
||||
|
||||
**Tech Stack:** C#/.NET 10, MediatR CQRS, EF Core, ChicoryTV React SPA (Vite + TypeScript), NUnit + Shouldly + NSubstitute (backend), Vitest + Testing Library (SPA).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Test framework is **NUnit + Shouldly + NSubstitute** (backend) and **Vitest** (SPA). Never xUnit.
|
||||
- New `/api/*` controllers/DTOs require OpenAPI regen: `dotnet build ErsatzTV.sln` → `./scripts/update-openapi.sh` → `cd web && npm run generate:api`, then commit `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, and `web/src/api/generated/v1.d.ts`. A blocking CI `api-docs` job fires on `ErsatzTV/Controllers/Api/**` and `ErsatzTV.Core/Api/**` changes.
|
||||
- Every controller action needs `[Tags("Playouts")]`, `[EndpointSummary(...)]`, `[EndpointGroupName("general")]` (REQUIRED or it's dropped from OpenAPI), and a `[ProducesResponseType]` per status code.
|
||||
- Success paths that only *queue* a build return **202 Accepted**, not 200.
|
||||
- Backend tests run timezone-pinned: `TZ=UTC dotnet test ErsatzTV.Tests`. SPA: `npm test` in `web/`.
|
||||
- Format touched C# files (de-BOM per the #311 formatting gate: `charset=utf-8`, no BOM on files you touch). Worktree hooks are flaky → commit with `--no-verify` and verify manually (`dotnet build`, `dotnet test`, `dotnet format whitespace <project>` on staged files only).
|
||||
- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
||||
- Work only in this worktree: `/Users/timothy/ersatztv/.worktrees/71-reshuffle` (branch `feat/71-reshuffle-playout`, off `origin/main`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ReshufflePlayout` command + handler
|
||||
|
||||
**Files:**
|
||||
- Create: `ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs`
|
||||
- Create: `ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs`
|
||||
- Test: `ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `BuildPlayout(int playoutId, PlayoutBuildMode mode)` (ctor), `PlayoutBuildMode.Reset` (`ErsatzTV.Core.Scheduling`), `IBackgroundServiceRequest` (`ErsatzTV.Application`, parent namespace — no `using` needed), `IDbContextFactory<TvContext>`, the `InMemoryTvContext` test harness (`ErsatzTV.Tests.Support`).
|
||||
- Produces: `public record ReshufflePlayout(int PlayoutId) : IRequest;` and `ReshufflePlayoutHandler` — later relied on by the controller (Task 2), which sends `new ReshufflePlayout(id)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing handler test**
|
||||
|
||||
Create `ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs` (mirrors `ResetAllPlayoutsHandlerTests.cs`; the handler enqueues a `Reset` build for a resettable kind and skips unsupported kinds):
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Channel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Playouts;
|
||||
|
||||
[TestFixture]
|
||||
public class ReshufflePlayoutHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private Channel<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private ReshufflePlayoutHandler CreateHandler() => new(_worker.Writer, _db.Factory);
|
||||
|
||||
private async Task<int> SeedPlayout(PlayoutScheduleKind kind)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
|
||||
context.Playouts.Add(playout);
|
||||
await context.SaveChangesAsync();
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
public async Task Handle_Should_Enqueue_Reset_Build_For_Supported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
int id = await SeedPlayout(kind);
|
||||
|
||||
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
|
||||
|
||||
_worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
var build = request.ShouldBeOfType<BuildPlayout>();
|
||||
build.PlayoutId.ShouldBe(id);
|
||||
build.Mode.ShouldBe(PlayoutBuildMode.Reset);
|
||||
_worker.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
[TestCase(PlayoutScheduleKind.None)]
|
||||
public async Task Handle_Should_Not_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
int id = await SeedPlayout(kind);
|
||||
|
||||
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
|
||||
|
||||
_worker.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ReshufflePlayoutHandlerTests`
|
||||
Expected: FAIL — `ReshufflePlayout` / `ReshufflePlayoutHandler` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Create the command**
|
||||
|
||||
Create `ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs`:
|
||||
|
||||
```csharp
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReshufflePlayout(int PlayoutId) : IRequest;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the handler**
|
||||
|
||||
Create `ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs`. The `Reset` build mode already reseeds + clears state + rebuilds; the handler only enqueues, and keeps the resettable-kind filter (defense-in-depth, matching `ErasePlayoutItemsHandler`):
|
||||
|
||||
```csharp
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ReshufflePlayoutHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ReshufflePlayout>
|
||||
{
|
||||
public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Block ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `SingleOrDefaultAsync` returns `Playout?`; the codebase's global usings (LanguageExt) implicitly convert to `Option<Playout>` as in `ErasePlayoutItemsHandler`. If the implicit conversion does not compile, wrap with `Optional(...)`.
|
||||
|
||||
- [ ] **Step 5: Run the test to verify it passes**
|
||||
|
||||
Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ReshufflePlayoutHandlerTests`
|
||||
Expected: PASS (6 cases).
|
||||
|
||||
- [ ] **Step 6: Format + commit**
|
||||
|
||||
```bash
|
||||
dotnet format whitespace ErsatzTV.Application ErsatzTV.Tests --folder --include ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs || true
|
||||
git add ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs ErsatzTV.Application/Playouts/Commands/ReshufflePlayoutHandler.cs ErsatzTV.Tests/Application/Playouts/ReshufflePlayoutHandlerTests.cs
|
||||
git commit --no-verify -m "feat(71): ReshufflePlayout command + handler (enqueue per-playout Reset build)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
> If `dotnet format --include` no-ops on this Mac (known issue), instead run `dotnet format whitespace ErsatzTV.Application` and `... ErsatzTV.Tests`, then `git add` only the three files above.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Reshuffle controller action (202 / 404 / 409 / 422)
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV/Controllers/Api/PlayoutController.cs` (add the `Reshuffle` action after `EraseItemsAndHistory`, ~line 685)
|
||||
- Test: `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ReshufflePlayout` (Task 1), `GetPlayoutById`, `entityLocker.IsPlayoutLocked(id)`, `PlayoutLockedProblem()`, `ApiResults.NotFoundProblem()`, `BaseError.New(...).ToErrorResult()`.
|
||||
- Produces: action `Task<IActionResult> Reshuffle(int id, CancellationToken)` at `POST /api/v1/playouts/{id:int}/reshuffle`.
|
||||
|
||||
- [ ] **Step 1: Write the failing controller tests**
|
||||
|
||||
Add to `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` (mirrors the `EraseItems_*` tests). Also extend the existing `Controller_Should_Expose_Idiomatic_Rest_Routes` test with the new route:
|
||||
|
||||
```csharp
|
||||
[Test]
|
||||
public async Task Reshuffle_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
[TestCase(PlayoutScheduleKind.None)]
|
||||
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
```
|
||||
|
||||
Add this line inside `Controller_Should_Expose_Idiomatic_Rest_Routes` (next to the `EraseItemsAndHistory` assertion):
|
||||
|
||||
```csharp
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests`
|
||||
Expected: FAIL — `PlayoutController.Reshuffle` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Add the controller action**
|
||||
|
||||
In `ErsatzTV/Controllers/Api/PlayoutController.cs`, insert immediately after the `EraseItemsAndHistory` action (after line 684):
|
||||
|
||||
```csharp
|
||||
[HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reshuffle a playout")]
|
||||
[EndpointDescription(
|
||||
"Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " +
|
||||
"and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
|
||||
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
|
||||
{
|
||||
return BaseError.New(
|
||||
"[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts")
|
||||
.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
await mediator.Send(new ReshufflePlayout(id), cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests`
|
||||
Expected: PASS (including the route assertion + the 4 new tests).
|
||||
|
||||
- [ ] **Step 5: Format + commit**
|
||||
|
||||
```bash
|
||||
dotnet format whitespace ErsatzTV ErsatzTV.Tests || true
|
||||
git add ErsatzTV/Controllers/Api/PlayoutController.cs ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs
|
||||
git commit --no-verify -m "feat(71): POST /api/v1/playouts/{id}/reshuffle action (202/404/409/422)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Surface `Seed` on the playout VM + DTOs + projections
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs` (append `int Seed`)
|
||||
- Modify: `ErsatzTV.Application/Playouts/Mapper.cs` (list projection)
|
||||
- Modify: `ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs` (detail projection)
|
||||
- Modify: `ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs`, `UpdateExternalJsonPlayoutHandler.cs`, `UpdateSequentialPlayoutHandler.cs`, `UpdateScriptedPlayoutHandler.cs` (VM construction sites)
|
||||
- Modify: `ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs`, `PlayoutResponseModel.cs`
|
||||
- Modify: `ErsatzTV/Controllers/Api/PlayoutController.cs` (`ToResponse`, `ToListItemResponse`)
|
||||
- Test: `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` (+ update the `MakePlayout` fixture)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PlayoutNameViewModel.Seed : int`, `PlayoutResponseModel.Seed : int`, `PlayoutListItemResponseModel.Seed : int` — consumed by the SPA (Task 6) after OpenAPI regen (Task 4).
|
||||
|
||||
- [ ] **Step 1: Write the failing seed tests**
|
||||
|
||||
Add to `PlayoutControllerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Test]
|
||||
public async Task GetById_Should_Surface_Seed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
|
||||
|
||||
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>()
|
||||
.Value.ShouldBeOfType<PlayoutResponseModel>()
|
||||
.Seed.ShouldBe(4242);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Surface_Seed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
||||
|
||||
result.Page[0].Seed.ShouldBe(4242);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter "FullyQualifiedName~PlayoutControllerTests.GetById_Should_Surface_Seed|FullyQualifiedName~PlayoutControllerTests.GetAll_Should_Surface_Seed"`
|
||||
Expected: FAIL — `PlayoutResponseModel`/`PlayoutListItemResponseModel` have no `Seed`; `MakePlayout(...) with { Seed = ... }` won't compile.
|
||||
|
||||
- [ ] **Step 3: Append `Seed` to `PlayoutNameViewModel`**
|
||||
|
||||
In `ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs`, change the record header's final line `int Version)` → `int Version,\n int Seed)`:
|
||||
|
||||
```csharp
|
||||
public record PlayoutNameViewModel(
|
||||
int PlayoutId,
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
string ScheduleName,
|
||||
string ScheduleFile,
|
||||
TimeSpan? DbDailyRebuildTime,
|
||||
PlayoutBuildStatus BuildStatus,
|
||||
int? DecoId,
|
||||
string DecoName,
|
||||
int Version,
|
||||
int Seed)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Set `Seed` at all VM construction sites**
|
||||
|
||||
`ErsatzTV.Application/Playouts/Mapper.cs` — change `playout.Version);` (line 23) to:
|
||||
```csharp
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
```
|
||||
|
||||
`ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs` — change `p.Version));` (line 34) to:
|
||||
```csharp
|
||||
p.Version,
|
||||
p.Seed));
|
||||
```
|
||||
|
||||
In each of `UpdatePlayoutHandler.cs`, `UpdateExternalJsonPlayoutHandler.cs`, `UpdateSequentialPlayoutHandler.cs`, `UpdateScriptedPlayoutHandler.cs` — the trailing `playout.Version);` of the `return new PlayoutNameViewModel(...)` becomes:
|
||||
```csharp
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `Seed` to the response DTOs**
|
||||
|
||||
`ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs` — append `int Seed` after `bool IsLocked`:
|
||||
```csharp
|
||||
public record PlayoutListItemResponseModel(
|
||||
int Id,
|
||||
string ChannelNumber,
|
||||
string ChannelName,
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ScheduleName,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
bool IsLocked,
|
||||
int Seed);
|
||||
```
|
||||
|
||||
`ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs` — append `int Seed` to both the record and the `From` factory (param list ends with `bool isLocked`; add `int seed`, and add `seed` to the `new(...)`):
|
||||
```csharp
|
||||
public record PlayoutResponseModel(
|
||||
int Id,
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
string ScheduleName,
|
||||
string? ScheduleFile,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
int? DecoId,
|
||||
string? DecoName,
|
||||
bool IsLocked,
|
||||
int Seed)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
string channelName,
|
||||
string channelNumber,
|
||||
ChannelPlayoutMode playoutMode,
|
||||
string scheduleName,
|
||||
string? scheduleFile,
|
||||
TimeSpan? dailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? buildStatus,
|
||||
int? decoId,
|
||||
string? decoName,
|
||||
bool isLocked,
|
||||
int seed) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
channelName,
|
||||
channelNumber,
|
||||
playoutMode,
|
||||
scheduleName,
|
||||
scheduleFile,
|
||||
dailyRebuildTime,
|
||||
buildStatus,
|
||||
decoId,
|
||||
decoName,
|
||||
isLocked,
|
||||
seed);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Pass `Seed` through the controller mappers**
|
||||
|
||||
`ErsatzTV/Controllers/Api/PlayoutController.cs`:
|
||||
- `ToResponse` (line 776) — change the trailing `isLocked);` to `isLocked,\n vm.Seed);`.
|
||||
- `ToListItemResponse` (line 828) — change the trailing `isLocked);` to `isLocked,\n vm.Seed);`.
|
||||
|
||||
- [ ] **Step 7: Update the `MakePlayout` fixture**
|
||||
|
||||
`ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` `MakePlayout` (line 1331) — the constructor ends `0);` (Version). Append `0` for Seed:
|
||||
```csharp
|
||||
0,
|
||||
0);
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run the seed tests + full controller/handler suite**
|
||||
|
||||
Run: `TZ=UTC dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PlayoutControllerTests`
|
||||
Expected: PASS (including the two seed tests). Then `TZ=UTC dotnet build ErsatzTV.sln` — Expected: `Build succeeded`, `0 Error(s)` (verifies all 4 update handlers + Mapper compile with the new positional field). Grep the output for `error CS` — there must be none.
|
||||
|
||||
- [ ] **Step 9: Format + commit**
|
||||
|
||||
```bash
|
||||
dotnet format whitespace ErsatzTV.Application ErsatzTV.Core ErsatzTV ErsatzTV.Tests || true
|
||||
git add ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs ErsatzTV.Application/Playouts/Mapper.cs ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs ErsatzTV/Controllers/Api/PlayoutController.cs ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs
|
||||
git commit --no-verify -m "feat(71): surface Playout.Seed on playout list + detail DTOs
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Regenerate OpenAPI + TypeScript client
|
||||
|
||||
**Files:**
|
||||
- Modify (generated): `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts`
|
||||
|
||||
- [ ] **Step 1: Build, regenerate, verify**
|
||||
|
||||
```bash
|
||||
dotnet build ErsatzTV.sln
|
||||
./scripts/update-openapi.sh
|
||||
cd web && npm run generate:api && npm run check:api && cd ..
|
||||
```
|
||||
Expected: `v1.json` gains the `/api/v1/playouts/{id}/reshuffle` POST (operationId `ReshufflePlayout`) and `seed` on `PlayoutListItemResponseModel` + `PlayoutResponseModel`; `docs/endpoint-index.md` gains the reshuffle row; `web/src/api/generated/v1.d.ts` gains `"seed": number;` on both models and the new path. `npm run check:api` exits clean.
|
||||
|
||||
- [ ] **Step 2: Commit the generated artifacts**
|
||||
|
||||
```bash
|
||||
git add ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md web/src/api/generated/v1.d.ts
|
||||
git commit --no-verify -m "chore(71): regenerate OpenAPI + client for reshuffle endpoint + seed field
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: SPA — reshuffle client fn + button
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/playouts.ts` (add `reshufflePlayout`)
|
||||
- Modify: `web/src/screens/PlayoutsScreen.tsx` (import `Dices`, add wrapper + button)
|
||||
- Test: `web/src/screens/PlayoutsScreen.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `request<void>` (from `./client`), the regenerated route (Task 4).
|
||||
- Produces: `reshufflePlayout(playoutId: number): Promise<void>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing SPA test**
|
||||
|
||||
Add to `web/src/screens/PlayoutsScreen.test.tsx` (mirrors the erase-buttons test):
|
||||
|
||||
```tsx
|
||||
it('reshuffles a Classic playout and posts to the reshuffle route', async () => {
|
||||
mockApi({
|
||||
confirm: true,
|
||||
playoutItems: [playoutItem()],
|
||||
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
|
||||
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<PlayoutsScreen />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reshuffle' }));
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
'/api/v1/playouts/20/reshuffle',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> If the `mockApi` fetch stub enumerates specific mutation routes (see `PlayoutsScreen.test.tsx:168-184`) rather than defaulting unknown POSTs to 200, add a matcher for `POST /api/v1/playouts/:id/reshuffle` mirroring the `erase-items` entry so the click resolves.
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd web && npm test -- PlayoutsScreen`
|
||||
Expected: FAIL — no button named "Reshuffle".
|
||||
|
||||
- [ ] **Step 3: Add the client fn**
|
||||
|
||||
In `web/src/api/playouts.ts`, add next to `erasePlayoutItemsAndHistory`:
|
||||
|
||||
```ts
|
||||
export function reshufflePlayout(playoutId: number): Promise<void> {
|
||||
return request<void>(`/api/v1/playouts/${playoutId}/reshuffle`, { method: 'POST' });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Import the icon + fn, add the wrapper + button**
|
||||
|
||||
In `web/src/screens/PlayoutsScreen.tsx`:
|
||||
|
||||
1. Add `Dices` to the `lucide-react` import block (alphabetical, after `Clock`):
|
||||
```tsx
|
||||
Dices,
|
||||
```
|
||||
|
||||
2. Add `reshufflePlayout` to the existing `from '../api'` import that already brings in `erasePlayoutItems` (add the name to that import list).
|
||||
|
||||
3. Add the wrapper fn next to `eraseSelectedItemsAndHistory`:
|
||||
```tsx
|
||||
const reshuffleSelectedPlayout = () => {
|
||||
if (!selectedSummary) {
|
||||
return;
|
||||
}
|
||||
runMutation(
|
||||
`Reshuffle the playout for ${selectedSummary.channelName}? This rolls a new random play order and rebuilds the schedule from scratch, clearing rerun history.`,
|
||||
() => reshufflePlayout(selectedSummary.id)
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
4. Add the button inside the `.ctv-playout-detail-actions` group (after the "Erase items and history" button, before "Delete"), gated to the reshuffle-supported kinds:
|
||||
```tsx
|
||||
{(selectedSummary.scheduleKind === 'Classic' ||
|
||||
selectedSummary.scheduleKind === 'Block' ||
|
||||
selectedSummary.scheduleKind === 'Sequential' ||
|
||||
selectedSummary.scheduleKind === 'Scripted') && (
|
||||
<Button
|
||||
disabled={mutating || selectedLocked}
|
||||
onClick={reshuffleSelectedPlayout}
|
||||
size="sm"
|
||||
startIcon={<Dices aria-hidden="true" size={13} />}
|
||||
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||
variant="ghost"
|
||||
>
|
||||
Reshuffle
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `cd web && npm test -- PlayoutsScreen`
|
||||
Expected: PASS (new test + existing tests still green).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/api/playouts.ts web/src/screens/PlayoutsScreen.tsx web/src/screens/PlayoutsScreen.test.tsx
|
||||
git commit --no-verify -m "feat(71): SPA per-playout Reshuffle button + client fn
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: SPA — seed display + help text
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/screens/PlayoutsScreen.tsx` (detail grid + help text)
|
||||
- Test: `web/src/screens/PlayoutsScreen.test.tsx` (+ `seed` in the `listPlayout()`/`playout()` factories)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
First ensure the test factories default a `seed`. In `PlayoutsScreen.test.tsx`, add `seed: 0` to the object returned by the `listPlayout()` factory and the `playout()` factory (mirroring how `isLocked`/`scheduleKind` defaults are set). Then add:
|
||||
|
||||
```tsx
|
||||
it('shows the play-order seed for the selected playout', async () => {
|
||||
mockApi({
|
||||
playoutItems: [playoutItem()],
|
||||
playoutDetails: playout({ id: 20, scheduleKind: 'Classic', seed: 424242 }),
|
||||
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic', seed: 424242 })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<PlayoutsScreen />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('424242')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd web && npm test -- PlayoutsScreen`
|
||||
Expected: FAIL — no element displays `424242`.
|
||||
|
||||
- [ ] **Step 3: Add the seed line + help text**
|
||||
|
||||
In `web/src/screens/PlayoutsScreen.tsx`, inside the `.ctv-playout-detail-grid` block (after the "Rebuild" `Input`), add:
|
||||
|
||||
```tsx
|
||||
<Input disabled label="Seed" value={String(playout?.seed ?? selectedSummary.seed)} />
|
||||
```
|
||||
|
||||
Immediately after the closing `</div>` of `.ctv-playout-detail-grid`, add the help text:
|
||||
|
||||
```tsx
|
||||
<p className="ctv-playout-seed-help">
|
||||
The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order.
|
||||
</p>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd web && npm test -- PlayoutsScreen`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/screens/PlayoutsScreen.tsx web/src/screens/PlayoutsScreen.test.tsx
|
||||
git commit --no-verify -m "feat(71): SPA display play-order seed + persistence help text
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs (parity, domain-model, decisions)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/blazor-route-parity.md`, `docs/domain-model.md`, `docs/decisions.md`
|
||||
|
||||
- [ ] **Step 1: Update `docs/blazor-route-parity.md`**
|
||||
|
||||
In the Playouts row / section, note the new per-playout **Reshuffle** action (`POST /api/v1/playouts/{id}/reshuffle`) alongside Reset/Erase/Delete. (Find the Playouts entry; add one sentence.)
|
||||
|
||||
- [ ] **Step 2: Update `docs/domain-model.md`**
|
||||
|
||||
In the playout rows (~lines 45-61), add a note that `Playout.Seed` (the master shuffle seed) is now surfaced on the playout list/detail API and that a per-playout **Reshuffle** action reseeds + rebuilds (`PlayoutBuildMode.Reset`).
|
||||
|
||||
- [ ] **Step 3: Append to `docs/decisions.md`**
|
||||
|
||||
Append a dated entry (append-only):
|
||||
```markdown
|
||||
## 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)
|
||||
|
||||
- The shuffle seed (`Playout.Seed`) + per-collection `CollectionEnumeratorState` already persist a stable
|
||||
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.
|
||||
- `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).
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/blazor-route-parity.md docs/domain-model.md docs/decisions.md
|
||||
git commit --no-verify -m "docs(71): reshuffle action + seed surfacing (parity, domain-model, decisions)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Full verification, live-E2E, PR, independent review
|
||||
|
||||
- [ ] **Step 1: Full local gate**
|
||||
|
||||
```bash
|
||||
TZ=UTC dotnet build ErsatzTV.sln # 0 errors
|
||||
TZ=UTC dotnet test ErsatzTV.Tests # all green
|
||||
cd web && npm run lint && npm test -- --run && npm run build && npm run check:api && cd ..
|
||||
git diff --stat origin/main # sanity: only intended files
|
||||
```
|
||||
Confirm each command's output shows success before proceeding (evidence, not assumption).
|
||||
|
||||
- [ ] **Step 2: Add `## Done-when` to issue #71** (merge-gate H6). Post/update the issue body with a checklist: adversarial review passed; backend + SPA tests green; OpenAPI regenerated + `check:api` clean; docs updated (parity/domain-model/decisions); live-E2E (reshuffle changes seed + order; 409 while locked).
|
||||
|
||||
- [ ] **Step 3: Live-E2E** (required — write-path handler that enqueues a build). Follow `docs/e2e-local.md` "Seeding a local TV library for E2E":
|
||||
- Start a local instance via `scripts/e2e-local.sh`; seed a Classic playout whose schedule item uses a Shuffle playback order over a small collection.
|
||||
- `GET /api/v1/playouts/{id}` → record `seed` and `GET /api/v1/playouts/{id}/items` → record the first several item titles.
|
||||
- `POST /api/v1/playouts/{id}/reshuffle` → 202. Wait for the build to complete (poll `buildStatus`).
|
||||
- Re-GET → assert `seed` changed **and** the item order changed.
|
||||
- Hold the build lock (or reshuffle twice rapidly) → assert a `409` on the second call.
|
||||
- Drive the SPA button in headless Playwright (never open download endpoints in a tab); confirm the confirm-dialog + button disable while building. Capture evidence.
|
||||
|
||||
- [ ] **Step 4: Push + open PR**
|
||||
|
||||
```bash
|
||||
git push --no-verify -u origin feat/71-reshuffle-playout
|
||||
```
|
||||
Open a PR to `main` with body describing the premise correction + `fixes #71`. Arm a CI monitor on the PR head sha immediately (commit-status endpoint).
|
||||
|
||||
- [ ] **Step 5: Independent review** (required — write-path + enqueues a build; NOT skippable). Run a cold-context adversarial review scoped "review only" over the PR diff (cross-model/Codex if quota, else a cold Claude agent). When judging the reshuffle soundness, enumerate every producer/consumer of `BuildPlayout` and confirm the per-playout `Reset` cannot cross-release another playout's lock. Post a `Review-verdict: <MERGEABLE|BLOCKED> @ <head-sha>` comment (H10). Fix findings as follow-up commits (never amend/force-push); re-review the fix commit.
|
||||
|
||||
- [ ] **Step 6: Merge** via the derived-consent gate (all `## Done-when` ticked + green CI + fresh positive `Review-verdict`), then run the issue close protocol (structured close comment; move nothing in #237 — it's closed) and the session-end tracker/H12 steps.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Design — Per-playout Reshuffle + shuffle-state surfacing (#71)
|
||||
|
||||
Date: 2026-07-16
|
||||
Issue: [ersatztv#71](http://192.168.1.95:3000/timothy/ersatztv/issues/71) — "Persistent shuffle state + explicit 'Reroll' control"
|
||||
|
||||
## Premise correction
|
||||
|
||||
The issue's stated premise is largely **false against the current code** (verified by a full
|
||||
shuffle/seed source map):
|
||||
|
||||
- **"Shuffle is re-randomized every rebuild" — FALSE.** Only `PlayoutBuildMode.Reset` reseeds
|
||||
(`ErsatzTV.Core/Scheduling/PlayoutBuilder.cs:301`, `playout.Seed = new Random().Next()`).
|
||||
`Continue` and `Refresh` reuse the persisted seed + position.
|
||||
- **"No persistent shuffle memory today" — FALSE.** `Playout.Seed` (int column, migration
|
||||
`20240124204743_Add_PlayoutSeed`) plus owned `CollectionEnumeratorState` (Seed + Index) rows on
|
||||
`PlayoutProgramScheduleAnchor`/`PlayoutAnchor` already persist the exact shuffled order **and** the
|
||||
current position across rebuilds and restarts. The Fisher-Yates shuffle in
|
||||
`ShuffledMediaCollectionEnumerator` is fully deterministic given the seed.
|
||||
- **"Anti-repeat exists but is implicit" — TRUE.** Each item plays once per permutation before any
|
||||
reshuffle; the in-cycle reshuffle even rejects a seed whose new head equals the previous tail.
|
||||
|
||||
So the persistence #71 asks us to build **already exists**. The genuine, missing gaps are:
|
||||
|
||||
1. **No user-triggered, per-playout reshuffle.** The only reseed path is `POST /api/v1/playouts/reset-all`,
|
||||
which for **Classic** playouts enqueues `Refresh` (preserves the seed), not `Reset`. A Classic
|
||||
channel's shuffle seed is therefore effectively frozen after first build — there is no way to roll a
|
||||
new order for one channel.
|
||||
2. **No visibility** into the shuffle state.
|
||||
|
||||
## Scope (approved)
|
||||
|
||||
"Reshuffle + state surfacing." Out of scope: a separate lighter "reseed-only" mode that preserves rerun
|
||||
history (rejected — YAGNI for the common Classic+Shuffle case, where reseeding inherently clears
|
||||
per-collection anchors); a bulk "reshuffle all" (the existing `reset-all` covers bulk rebuild).
|
||||
|
||||
## A. Backend — Reshuffle action
|
||||
|
||||
- **Command:** `ErsatzTV.Application/Playouts/Commands/ReshufflePlayout.cs` —
|
||||
`public record ReshufflePlayout(int PlayoutId) : IRequest;` (fire-and-forget, no result body).
|
||||
- **Handler:** `ReshufflePlayoutHandler(ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)`. Follows the `EraseItems` shape (controller owns the
|
||||
404/409/422 prechecks; the handler is a pure fire-and-forget enqueue): load the playout with a
|
||||
resettable-`ScheduleKind` filter, `foreach (playout in maybePlayout)` (silent no-op if absent),
|
||||
enqueue `BuildPlayout(PlayoutId, PlayoutBuildMode.Reset)`. The `Reset` build mode already performs
|
||||
reseed → clear anchors/history → rebuild; the handler does **not** manually reseed and does **not**
|
||||
re-check the lock (the controller 409-guards and the build worker acquires its own lock).
|
||||
- **Controller action** in `ErsatzTV/Controllers/Api/PlayoutController.cs`, mirroring `EraseItems`:
|
||||
```
|
||||
[HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")]
|
||||
[Tags("Playouts")] [EndpointGroupName("general")]
|
||||
[EndpointSummary("Reshuffle a playout — roll a new random play order and rebuild")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), 404)] // playout not found
|
||||
[ProducesResponseType(typeof(ProblemDetails), 409)] // build in progress
|
||||
[ProducesResponseType(typeof(ProblemDetails), 422)] // unsupported schedule kind
|
||||
```
|
||||
Body order (mirrors erase-items): `IsPlayoutLocked(id)` → `PlayoutLockedProblem()` (409);
|
||||
`GetPlayoutById(id)` `IsNone` → `ApiResults.NotFoundProblem()` (404); `ScheduleKind` not in
|
||||
{Classic, Block, Sequential, Scripted} → `BaseError.New(...).ToErrorResult()` (422); else
|
||||
`await mediator.Send(new ReshufflePlayout(id), ct)` → `Accepted()` (202, no body — single playout).
|
||||
|
||||
### Semantics (decisions.md entry)
|
||||
|
||||
- Named `/reshuffle`, **not** `/reset`, to (a) match the user intent, (b) avoid the confusing
|
||||
"reset-one reseeds Classic while reset-all refreshes Classic" naming clash.
|
||||
- It **is** a full per-playout `Reset`: reseed + clear per-collection anchors + **clear rerun history** +
|
||||
rebuild. For Classic+Shuffle this yields a genuinely new Fisher-Yates order; for Chronological it is a
|
||||
harmless rebuild; for Block it re-randomizes content selection and resets block rotation. The SPA
|
||||
confirm dialog states the rerun-history side effect explicitly.
|
||||
- Per-playout reshuffle deliberately reseeds Classic even though `reset-all` refreshes Classic:
|
||||
a targeted single-channel action is an explicit "give me a new order" request; the bulk action stays
|
||||
non-disruptive by design.
|
||||
|
||||
## B. Backend — Seed surfacing
|
||||
|
||||
Add `int Seed` to:
|
||||
|
||||
- `ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs`
|
||||
- `ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs` (list) and `PlayoutResponseModel.cs` (detail)
|
||||
|
||||
Wire the projections that build the VM:
|
||||
|
||||
- `ErsatzTV.Application/Playouts/Mapper.cs` `ProjectToViewModel(Playout playout)` (used by
|
||||
`GetPagedPlayoutsHandler`)
|
||||
- `ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs`
|
||||
|
||||
and the controller mappers `ToResponse` / `ToListItemResponse` in `PlayoutController.cs`.
|
||||
|
||||
Purpose: the seed changes after a reshuffle, giving the user **visible confirmation** the action worked.
|
||||
|
||||
## C. SPA
|
||||
|
||||
Files: `web/src/api/playouts.ts`, `web/src/screens/PlayoutsScreen.tsx`, regenerated
|
||||
`web/src/api/generated/v1.d.ts`.
|
||||
|
||||
- `reshufflePlayout(playoutId: number)` client fn → `POST /playouts/{id}/reshuffle` via the shared
|
||||
`request()` helper (CSRF added centrally).
|
||||
- New per-playout **"Reshuffle"** button in the action block (the `erase-*`/`delete` group), lucide
|
||||
`Dices` (or `Shuffle`) icon, `disabled={mutating || selectedLocked}`,
|
||||
`title={selectedLocked ? 'A build is in progress for this playout' : undefined}`, kind-gated to
|
||||
{Classic, Block, Sequential, Scripted}. Wired via the existing `runMutation(confirmMessage, action)`
|
||||
helper (guards re-entrancy, `window.confirm`, `query.refresh()` on success, and on `ApiError` 409 also
|
||||
refreshes so the row picks up `IsLocked`).
|
||||
- Confirm message: *"Reshuffle this playout? This rolls a new random play order and rebuilds the
|
||||
schedule from scratch, clearing rerun history."*
|
||||
- Selected-playout card: show the **seed** (monospace, e.g. `Play-order seed: 1847362`) plus one line of
|
||||
help text: *"The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new
|
||||
order."* Seed comes from the already-loaded list row.
|
||||
|
||||
No new SPA convention is introduced (reuses `runMutation` + `window.confirm` + the action-button
|
||||
pattern), so `spa-conventions.md` needs no change.
|
||||
|
||||
## D. Docs (same PR)
|
||||
|
||||
- Regenerate OpenAPI + client: `dotnet build ErsatzTV.sln` → `./scripts/update-openapi.sh`
|
||||
(updates `ErsatzTV/wwwroot/openapi/v1.json` + `docs/endpoint-index.md`) →
|
||||
`cd web && npm run generate:api` (updates `web/src/api/generated/v1.d.ts`). Commit all three.
|
||||
- `docs/blazor-route-parity.md` — Playouts row: note the new reshuffle action.
|
||||
- `docs/domain-model.md` — playout rows: note `Playout.Seed` is now surfaced and the reshuffle action.
|
||||
- `docs/decisions.md` — append the semantics entry above.
|
||||
- `docs/README.md` — no change (no doc added/removed/retitled).
|
||||
|
||||
## E. Tests
|
||||
|
||||
- **Handler** (Application test harness): `ReshufflePlayoutHandler` writes exactly one
|
||||
`BuildPlayout(id, PlayoutBuildMode.Reset)` to the channel for a resettable kind; no write for a locked
|
||||
playout / unsupported kind (whichever the handler is responsible for — controller owns 409/422, so the
|
||||
handler test asserts the enqueue for the happy path). Capture channel writes via a test
|
||||
`Channel<IBackgroundServiceRequest>`.
|
||||
- **API/controller** (`ErsatzTV.Tests` harness, mirroring the erase-items / ChannelController tests):
|
||||
202 on a valid resettable playout; 404 for a missing id; 409 when the build lock is held; 422 for an
|
||||
ExternalJson/None playout.
|
||||
- **DTO**: the playout list/detail responses include `Seed`.
|
||||
- **SPA** (`PlayoutsScreen.test.tsx`): the Reshuffle button calls `reshufflePlayout`, respects the
|
||||
confirm dialog, refreshes on success; the seed renders in the selected-playout card.
|
||||
|
||||
## F. Verification gate
|
||||
|
||||
- Backend: `dotnet build ErsatzTV.sln`, `dotnet test` (touched projects), `dotnet format` on touched
|
||||
files (de-BOM per the #311 formatting gate).
|
||||
- OpenAPI: `npm run check:api` clean; the three generated artifacts committed.
|
||||
- SPA: `npm test`, `npm run lint`, `npm run build`.
|
||||
- **Live-E2E (required — write-path handler that enqueues a build):** via `scripts/e2e-local.sh` —
|
||||
seed a Classic playout with a Shuffle-order schedule, record the seed + first items, reshuffle, and
|
||||
assert the seed changed and the built order changed; verify a 409 while a build lock is held. Never
|
||||
exercise download endpoints via a browser tab.
|
||||
- **Independent review (required — write-path + enqueues a build; not skippable):** cold-context
|
||||
adversarial review scoped "review only" over the PR diff, then re-review the fix commit. Cross-model
|
||||
(Codex) if quota allows, else a cold Claude agent. Enumerate every producer/consumer of the
|
||||
`BuildPlayout` message and the reset path when judging soundness.
|
||||
|
||||
## Risks / edge cases
|
||||
|
||||
- **Rerun-history loss** is intentional but user-visible; the confirm dialog must call it out.
|
||||
- **Concurrent build**: the controller 409-guards, and the build worker acquires its own lock, so an
|
||||
enqueue that races a starting build is benign (the worker serializes). No new lock is introduced.
|
||||
- **Chronological / non-random playbacks**: reshuffle is a harmless rebuild (no visible order change);
|
||||
the button is still offered per-kind, not per-playback-order, since a playout can mix items.
|
||||
- **Seed = 0** (never-reset playout) still shuffles deterministically; surfacing it is fine.
|
||||
Vendored
+2
@@ -1116,6 +1116,7 @@ export interface components {
|
||||
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||
"isLocked": boolean;
|
||||
"seed": number;
|
||||
};
|
||||
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
|
||||
"PlayoutResponseModel": {
|
||||
@@ -1131,6 +1132,7 @@ export interface components {
|
||||
"decoId": null | number;
|
||||
"decoName": null | string;
|
||||
"isLocked": boolean;
|
||||
"seed": number;
|
||||
};
|
||||
"PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson";
|
||||
"PlayoutSettingsResponseModel": {
|
||||
|
||||
@@ -133,6 +133,10 @@ export function erasePlayoutItemsAndHistory(playoutId: number): Promise<void> {
|
||||
return request<void>(`/api/v1/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function reshufflePlayout(playoutId: number): Promise<void> {
|
||||
return request<void>(`/api/v1/playouts/${playoutId}/reshuffle`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getPlayoutItemSchedulingContext(itemId: number): Promise<PlayoutItemSchedulingContext> {
|
||||
return request<PlayoutItemSchedulingContext>(`/api/v1/playouts/items/${itemId}/scheduling-context`);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ function listPlayout(overrides: Record<string, unknown> = {}): Record<string, un
|
||||
isLocked: false,
|
||||
scheduleKind: 'Classic',
|
||||
scheduleName: 'Prime Time Cartoons',
|
||||
seed: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -33,6 +34,7 @@ function playout(overrides: Record<string, unknown> = {}): Record<string, unknow
|
||||
return listPlayout({
|
||||
playoutMode: 'Continuous',
|
||||
scheduleFile: null,
|
||||
seed: 0,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
@@ -183,6 +185,15 @@ function mockApi(options: MockOptions = {}): void {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
|
||||
if (/^\/api\/v1\/playouts\/\d+\/reshuffle$/.test(path)) {
|
||||
const failure = mutationFailures[path];
|
||||
if (failure) {
|
||||
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
|
||||
if (/^\/api\/v1\/playouts\/\d+\/deco$/.test(path)) {
|
||||
const failure = mutationFailures[path];
|
||||
if (failure) {
|
||||
@@ -600,6 +611,40 @@ describe('PlayoutsScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reshuffles a Classic playout and posts to the reshuffle route', async () => {
|
||||
mockApi({
|
||||
confirm: true,
|
||||
playoutItems: [playoutItem()],
|
||||
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
|
||||
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<PlayoutsScreen />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reshuffle' }));
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
'/api/v1/playouts/20/reshuffle',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the play-order seed for the selected playout', async () => {
|
||||
mockApi({
|
||||
playoutItems: [playoutItem()],
|
||||
playoutDetails: playout({ id: 20, scheduleKind: 'Classic', seed: 424242 }),
|
||||
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic', seed: 424242 })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<PlayoutsScreen />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('424242')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only erase-items-and-history for a Classic playout', async () => {
|
||||
mockApi({
|
||||
playoutItems: [playoutItem()],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Dices,
|
||||
Eraser,
|
||||
Film,
|
||||
Info,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
getPlayoutItemSchedulingContext,
|
||||
getSchedules,
|
||||
messageFromError,
|
||||
reshufflePlayout,
|
||||
resetAllPlayouts,
|
||||
resetChannelPlayout,
|
||||
updatePlayoutDefaultDeco,
|
||||
@@ -453,6 +455,16 @@ export function PlayoutsScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const reshuffleSelectedPlayout = () => {
|
||||
if (!selectedSummary) {
|
||||
return;
|
||||
}
|
||||
runMutation(
|
||||
`Reshuffle the playout for ${selectedSummary.channelName}? This rolls a new random play order and rebuilds the schedule from scratch, clearing rerun history.`,
|
||||
() => reshufflePlayout(selectedSummary.id)
|
||||
);
|
||||
};
|
||||
|
||||
const submitAddPlayout = (request: CreatePlayoutRequest) => {
|
||||
setAddBusy(true);
|
||||
setAddError(null);
|
||||
@@ -633,7 +645,16 @@ export function PlayoutsScreen() {
|
||||
<Input disabled label="Schedule" value={playout?.scheduleName ?? selectedSummary.scheduleName} />
|
||||
<Input disabled label="Kind" value={formatScheduleEnum(playout?.scheduleKind ?? selectedSummary.scheduleKind)} />
|
||||
<Input disabled label="Rebuild" value={formatDailyRebuild(playout?.dailyRebuildTime ?? selectedSummary.dailyRebuildTime)} />
|
||||
<Input disabled label="Seed" value={String(playout?.seed ?? selectedSummary.seed)} />
|
||||
</div>
|
||||
{(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
|
||||
@@ -703,6 +724,21 @@ export function PlayoutsScreen() {
|
||||
Erase items and history
|
||||
</Button>
|
||||
)}
|
||||
{(selectedSummary.scheduleKind === 'Classic' ||
|
||||
selectedSummary.scheduleKind === 'Block' ||
|
||||
selectedSummary.scheduleKind === 'Sequential' ||
|
||||
selectedSummary.scheduleKind === 'Scripted') && (
|
||||
<Button
|
||||
disabled={mutating || selectedLocked}
|
||||
onClick={reshuffleSelectedPlayout}
|
||||
size="sm"
|
||||
startIcon={<Dices aria-hidden="true" size={13} />}
|
||||
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||
variant="ghost"
|
||||
>
|
||||
Reshuffle
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={mutating || selectedLocked}
|
||||
onClick={deleteSelectedPlayout}
|
||||
|
||||
Reference in New Issue
Block a user