feat(top bar): EDIT decides live vs test on the primary button — the TEST button is gone

Creator ruling (2026-10-30):
- EDIT off + ON-AIR armed → the primary button reads "Go Live" and runs the livestream.
- EDIT on + ON-AIR armed → the same button reads "Test Stream" and runs the test broadcast.
- REC armed (or nothing armed) → "Start Recording", whatever EDIT says; there is no test
  recording, and an unarmed start records locally rather than doing nothing.

So the fifth button was redundant: `ShowTestButton` no longer feeds any control. The decision is a
pure function (`Models/SessionStartDecision`) so the whole matrix is testable without a window;
`PrimaryStartCommand` follows the same kind, so label and action cannot disagree.

Guard: SessionStartDecisionTests pins the four cases, including the no-dead-ends fallback that my
first version got wrong (unarmed returned Live instead of Record — the test caught it).

Note for the v1 gate: the "hide TEST at v1" item is now satisfied by construction — the test path
is a mode of the primary button, not a separate control.
This commit is contained in:
2026-10-30 11:14:33 -07:00
parent 9b08c3af4f
commit fbeb04385b
5 changed files with 103 additions and 12 deletions
+3 -8
View File
@@ -100,15 +100,10 @@
<!-- Go face names the world: "Start Recording" vs "Go Live".
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
Height="32" Margin="10,0,0,0" Command="{Binding StartStreamCommand}"
Height="32" Margin="10,0,0,0" Command="{Binding PrimaryStartCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
<!-- Test is a child of Stream — a dry-run of the livestream, so it
exists only inside the Stream world (stream armed + signed in). -->
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
Height="32" Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding StartTestCommand}"
ToolTip="Run a private test broadcast to check your chat overlay."
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
<!-- No separate TEST button (creator ruling 2026-10-30): EDIT decides whether
the ON-AIR start is the real livestream or the test one. -->
</StackPanel>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
+38
View File
@@ -0,0 +1,38 @@
namespace ytLive.Models;
/// <summary>What the top bar's primary action button will do.</summary>
public enum SessionStartKind
{
Record,
Live,
Test,
}
/// <summary>Creator ruling (2026-10-30): the primary button is context-sensitive, so the separate
/// TEST button is gone.
///
/// <list type="bullet">
/// <item>EDIT off + ON-AIR armed → Go Live (the livestream message)</item>
/// <item>EDIT on + ON-AIR armed → Test Stream (the test-livestream message) — a dry run you
/// cannot leak, because every broadcast is private pre-v1 anyway</item>
/// <item>REC armed → Start Recording, whatever EDIT says (there is no test recording)</item>
/// </list>
///
/// <para>Pure so the whole matrix is testable without a window.</para></summary>
public static class SessionStartDecision
{
public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
{
// Neither pill armed still starts something: local recording needs no account, and the
// no-dead-ends rule forbids an inert primary button.
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
return editing ? SessionStartKind.Test : SessionStartKind.Live;
}
public static string LabelFor(SessionStartKind kind) => kind switch
{
SessionStartKind.Record => "Start Recording",
SessionStartKind.Test => "Test Stream",
_ => "Go Live",
};
}
+6 -2
View File
@@ -85,8 +85,12 @@ public partial class MainViewModel
get => _editingActive;
set
{
if (SetProperty(ref _editingActive, value))
OnPropertyChanged(nameof(IsEditMode));
if (!SetProperty(ref _editingActive, value)) return;
OnPropertyChanged(nameof(IsEditMode));
// EDIT decides whether the ON-AIR start is live or a test (creator ruling 2026-10-30).
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
}
}
+24 -2
View File
@@ -72,6 +72,9 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(OnAirBrush));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
@@ -109,6 +112,9 @@ public partial class MainViewModel : ViewModelBase
if (!SetProperty(ref _recordPillOn, value)) return;
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(CanStartSession));
RaiseTopBarModes();
}
@@ -125,6 +131,9 @@ public partial class MainViewModel : ViewModelBase
if (!SetProperty(ref _onAirPillOn, value)) return;
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(CanStartSession));
RaiseTopBarModes();
}
@@ -146,6 +155,9 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
RaiseTopBarModes();
@@ -169,8 +181,18 @@ public partial class MainViewModel : ViewModelBase
/// routes to the sign-in inside StartSession (Start — the account step shows up
/// beside it as the Sign In button); a signed-out go-live stays the dead-end the
/// creator ruling 2026-09-20-02 forbids.</summary>
public string PrimaryStartButtonLabel =>
OnAirPillOn ? "Go Live" : "Start Recording";
/// <summary>Creator ruling 2026-10-30: EDIT decides whether the ON-AIR start is the real
/// livestream or the test one, which is why the separate TEST button is gone.</summary>
public SessionStartKind PrimaryStartKind =>
SessionStartDecision.For(RecordPillOn, OnAirPillOn, EditingActive);
public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
/// <summary>The button's action follows its label: Test runs the same go-live pipeline with
/// `IsTestStream` set, Go Live runs it for real.</summary>
public ICommand PrimaryStartCommand => PrimaryStartKind == SessionStartKind.Test
? StartTestCommand
: StartStreamCommand;
/// <summary>The End button shows while recording OR live.</summary>
public bool ShowEndStreamButton => IsLive || IsRecording;
+32
View File
@@ -0,0 +1,32 @@
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>Creator ruling (2026-10-30): the top bar's primary button is context-sensitive and the
/// separate TEST button is gone.
///
/// <para>EDIT off + ON-AIR → the livestream message. EDIT on + ON-AIR → the test-livestream
/// message. REC always records, because there is no test recording. The matrix is pure, so it is
/// cheaper to pin it here than to drive a window.</para></summary>
public class SessionStartDecisionTests
{
[Fact]
public void Edit_decides_whether_the_on_air_start_is_live_or_a_test()
{
// ON-AIR armed: EDIT decides.
Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: false));
Assert.Equal("Go Live", SessionStartDecision.LabelFor(SessionStartKind.Live));
Assert.Equal(SessionStartKind.Test, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: true));
Assert.Equal("Test Stream", SessionStartDecision.LabelFor(SessionStartKind.Test));
// REC armed: always a recording, whatever EDIT says (no test recording exists).
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, false));
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, true));
Assert.Equal("Start Recording", SessionStartDecision.LabelFor(SessionStartKind.Record));
// Unarmed start records locally (the no-dead-ends rule).
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false, false));
}
}