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:
2026-07-16 22:54:37 +02:00
co-authored by Claude Opus 4.8
parent 9a7ac28f9f
commit 1f4341a347
@@ -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.