docs(71): implementation plan for per-playout reshuffle + seed surfacing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user