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:
@@ -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 -->
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user