Files
LlamaCasty/ytLive.Tests/BackOnAirTests.cs
T
gramps dd51f7842a feat(stream): one-click Back on air — a new broadcast, honestly labelled (TASK 32·3)
The last resilience slice. When a drop ends the broadcast, YouTube cannot resume it, so the only way
back is a new one — and that is what the app now offers, in one click.

- `Services/BroadcastTitle.cs` — the resume marker is applied to the title the show was using, and any
  existing marker is REPLACED rather than stacked ("Show (resumed)" → "Show (resumed 2)"), with the base
  truncated so the whole title fits YouTube's 100-character limit. Otherwise a channel grows two
  identically named videos and the creator has to explain it.
- `MainViewModel.BackOnAirCommand` — reuses the ordinary go-live path with the saved details and the
  cached stream: new broadcast, new VOD, no dialog. It appears only after a drop ended the session and
  the ordinary start button steps aside, so there is exactly one way forward.
- The running line goes red OFF AIR (not amber) in that state; the button reads "● Back on air".

Tests: `BackOnAirTests` — first re-air marks the title, a second drop counts instead of stacking, the
100-character limit holds, an empty title still works, and the post-drop state exposes exactly one
start affordance. FAST gate: 552 passed, 0 failed, 0 warnings.

TASK 32 is now complete: fragmented MP4, blip retry, poll-driven RECONNECTING, ingest pre-flight, Back
on air.
2026-11-05 14:59:00 -08:00

66 lines
2.3 KiB
C#

using System;
using System.IO;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 32·3 — Back on air. Once YouTube completes a broadcast it cannot be resumed, so the only honest
/// route back is a NEW broadcast: one click, saved details, no dialog, and a title that says it is a
/// resume (otherwise the channel grows two identically named videos).
/// </summary>
public class BackOnAirTests
{
[Fact]
public void ResumeTitle_MarksTheFirstReAir()
=> Assert.Equal("Friday Night Show (resumed)", BroadcastTitle.ForResume("Friday Night Show", 1));
[Fact]
public void ResumeTitle_CountsASecondDrop_InsteadOfStackingMarkers()
=> Assert.Equal("Friday Night Show (resumed 2)", BroadcastTitle.ForResume("Friday Night Show (resumed)", 2));
[Fact]
public void ResumeTitle_FitsYouTubesHundredCharacterLimit()
{
var longTitle = new string('x', 140);
var resumed = BroadcastTitle.ForResume(longTitle, 1);
Assert.Equal(BroadcastTitle.MaxLength, resumed.Length);
Assert.EndsWith(" (resumed)", resumed);
}
[Fact]
public void ResumeTitle_HandlesAnEmptyTitle()
=> Assert.Equal("LlamaCasty (resumed)", BroadcastTitle.ForResume(" ", 1));
[Fact]
public void AfterADrop_TheCreatorIsOfferedOneClickBackOnAir()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-backonair-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var vm = new MainViewModel();
try
{
Assert.False(vm.IsOffAirAfterDrop);
Assert.False(vm.BackOnAirCommand.CanExecute(null));
vm.MarkOffAirAfterDropForTest("Friday Night Show");
Assert.True(vm.IsOffAirAfterDrop);
Assert.True(vm.BackOnAirCommand.CanExecute(null));
Assert.Equal("● Back on air", vm.BackOnAirLabel);
// The ordinary start button steps aside so there is exactly one way forward.
Assert.False(vm.ShowPrimaryStartButton);
}
finally
{
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}