diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml index 8a35538..bde997f 100644 --- a/Controls/TopBar.xaml +++ b/Controls/TopBar.xaml @@ -66,121 +66,168 @@ question "which world am I in?" and "can I edit?" cannot disagree. Clicking the highlighted half re-enters it, which is how a creator gets back to the layers during a session. --> - + - + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + - - - + - - - - - - - - - - - + - - - + + + + + + + + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index dfd9645..c2055de 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -204,10 +204,20 @@ public partial class MainWindow : Window // Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its // back chevron is gone in favour of this. The bar says so under the switches. - if (e.Key == System.Windows.Input.Key.Escape && _viewModel.Activity != ViewModels.TopActivity.Build) + // Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place + // rather than moving the bar around. + if (e.Key == System.Windows.Input.Key.Escape) { - _viewModel.SelectBuildActivityCommand.Execute(null); - e.Handled = true; + if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed) + { + _viewModel.CancelYouTubeCommand.Execute(null); + e.Handled = true; + } + else if (_viewModel.IsRecordArmed) + { + _viewModel.CancelRecordCommand.Execute(null); + e.Handled = true; + } } } diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 66df8ef..6efe07b 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -656,7 +656,7 @@ public partial class MainViewModel : ViewModelBase } OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(LiveElapsedText)); - OnPropertyChanged(nameof(RecordActionLabel)); + OnPropertyChanged(nameof(RecordElapsedLabel)); } private void OnLiveTimerTick(object? sender, EventArgs e) @@ -666,7 +666,7 @@ public partial class MainViewModel : ViewModelBase _recordLength = _liveElapsed; _recDotPulse = _recDotPulse > 0.5 ? 0.2 : 1.0; OnPropertyChanged(nameof(RecDotOpacity)); - OnPropertyChanged(nameof(RecordActionLabel)); + OnPropertyChanged(nameof(RecordElapsedLabel)); // TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain. if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 37c22cc..2b021d2 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -87,7 +87,7 @@ public partial class MainViewModel : ViewModelBase OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); RaiseTopBarModes(); - RaiseActivityStage(); // IsLive drives ShowOutputStage + RaiseSlotStates(); // IsLive drives ShowOutputStage UpdateLiveVisuals(); } } @@ -118,7 +118,7 @@ public partial class MainViewModel : ViewModelBase { if (!SetProperty(ref _recordPillOn, value)) return; if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR - RaiseActivityStage(); + RaiseSlotStates(); OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(PrimaryStartKind)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); @@ -138,7 +138,7 @@ public partial class MainViewModel : ViewModelBase // intent, not reality; the Start face turns this into a sign-in. if (!SetProperty(ref _onAirPillOn, value)) return; if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC - RaiseActivityStage(); + RaiseSlotStates(); OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(PrimaryStartKind)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); @@ -171,7 +171,7 @@ public partial class MainViewModel : ViewModelBase OnPropertyChanged(nameof(EndButtonText)); OnPropertyChanged(nameof(ShowTestButton)); RaiseTopBarModes(); - RaiseActivityStage(); // Start Recording <-> Stop Recording, and the REC clock + RaiseSlotStates(); // Start Recording <-> Stop Recording, and the REC clock UpdateSessionTimer(); } } diff --git a/ViewModels/MainViewModel.TopBar.cs b/ViewModels/MainViewModel.TopBar.cs index e82d9b7..ab49232 100644 --- a/ViewModels/MainViewModel.TopBar.cs +++ b/ViewModels/MainViewModel.TopBar.cs @@ -3,115 +3,161 @@ using ytLive.Helpers; namespace ytLive.ViewModels; -/// What the app is doing (creator, 2026-11-13): build, record, or stream. Three activities, -/// three buttons, always visible. -/// Which YouTube stream is armed. Choosing does not start it — Start does. -public enum StreamMode +/// Where the YouTube slot is in its own five-step flow. +public enum YouTubeSlotState { - None, - Live, - Private, + /// [ ▶ YouTube ] + Idle, + + /// [ ▶ Live | 🔒 Private | ✕ ] + ChoosePrivacy, + + /// [ ● Start Live | ✕ ] or [ ● Start Private | ✕ ] + Armed, + + /// [ ■ Stop Live ] or [ ■ Stop Private ] + Streaming, } -public enum TopActivity +/// Where the Record slot is in its own three-step flow. +public enum RecordSlotState { - Build, - YouTube, - Record, + /// [ ◉ Record ] + Idle, + + /// [ ● Start Recording | ✕ ] + Armed, + + /// [ ■ Stop Recording ] + Recording, } +/// +/// The top bar is THREE FIXED SLOTS (creator, 2026-11-13): Build Scenes, YouTube, Record. A slot never +/// changes the bar's shape — every state transition happens INSIDE its own slot, and the neighbouring +/// slots never move. Earlier attempts added completion states as siblings in the bar, which read as five +/// ungrouped toolbar items and pushed Record around. +/// public partial class MainViewModel { - private TopActivity _activity = TopActivity.Build; + private YouTubeSlotState _youTubeStep = YouTubeSlotState.Idle; + private RecordSlotState _recordStep = RecordSlotState.Idle; + private bool _podcastLive; // true = Private chosen (the private-visibility test stream) - public TopActivity Activity => _activity; + // ─── Build Scenes slot ─── + // Enabled only while BOTH other slots are idle: editing a scene under a live stream or a running + // recording is what the lock is for, and the tooltip says so out loud. + public bool IsBuildScenesEnabled => + _youTubeStep == YouTubeSlotState.Idle && _recordStep == RecordSlotState.Idle; - public bool IsBuildActivity => _activity == TopActivity.Build; - public bool IsYouTubeActivity => _activity == TopActivity.YouTube; - public bool IsRecordActivity => _activity == TopActivity.Record; + public string BuildScenesTooltip => IsBuildScenesEnabled + ? "Edit layers, scenes and webcam placement" + : "Scene editing is locked while streaming or recording"; - // ─── Each activity's own flow ─── - // Build: nothing to start. YouTube: Live or Private, then Stop. Record: Start, then Stop. + // ─── YouTube slot ─── + public YouTubeSlotState YouTubeStep => _youTubeStep; + public bool IsYouTubeIdle => _youTubeStep == YouTubeSlotState.Idle; + public bool IsYouTubeChoosingPrivacy => _youTubeStep == YouTubeSlotState.ChoosePrivacy; + public bool IsYouTubeArmed => _youTubeStep == YouTubeSlotState.Armed; + public bool IsYouTubeStreaming => _youTubeStep == YouTubeSlotState.Streaming; - // YouTube is two steps ON PURPOSE (creator, 2026-11-13): choosing Live or Private only ARMS it, - // because Start is the last moment to tell someone "check you are ready". Collapsing the two would - // trade that prompt for one fewer click. - private StreamMode _streamMode = StreamMode.None; + public bool IsLiveChosen => !_podcastLive; + public bool IsPrivateChosen => _podcastLive; + public string YouTubeArmLabel => _podcastLive ? "Start Private" : "Start Live"; + public string YouTubeStopLabel => _podcastLive ? "Stop Private" : "Stop Live"; - public bool IsLiveModeChosen => _streamMode == StreamMode.Live; - public bool IsPrivateModeChosen => _streamMode == StreamMode.Private; - - public bool ShowYouTubeModeChoices => IsYouTubeActivity && !IsLive && _streamMode == StreamMode.None; - public bool ShowYouTubeStart => IsYouTubeActivity && !IsLive && _streamMode != StreamMode.None; - public bool ShowYouTubeStop => IsYouTubeActivity && IsLive; - public bool ShowRecordStart => IsRecordActivity && !IsRecording; - public bool ShowRecordStop => IsRecordActivity && IsRecording; - - /// The Record activity's own words: its name while idle, its action while chosen. - public string RecordActionLabel => IsRecording ? "Stop Recording" : "Start Recording"; + // ─── Record slot ─── + public RecordSlotState RecordStep => _recordStep; + public bool IsRecordIdle => _recordStep == RecordSlotState.Idle; + public bool IsRecordArmed => _recordStep == RecordSlotState.Armed; + public bool IsRecordRecording => _recordStep == RecordSlotState.Recording; public string RecordElapsedLabel => $"REC: {LiveElapsedText}"; - public ICommand SelectBuildActivityCommand { get; private set; } = null!; - public ICommand SelectYouTubeActivityCommand { get; private set; } = null!; - public ICommand SelectRecordActivityCommand { get; private set; } = null!; - public ICommand ChooseLiveModeCommand { get; private set; } = null!; - public ICommand ChoosePrivateModeCommand { get; private set; } = null!; - public ICommand StartChosenStreamCommand { get; private set; } = null!; + public ICommand SelectBuildCommand { get; private set; } = null!; + public ICommand OpenYouTubeCommand { get; private set; } = null!; + public ICommand ChooseLiveCommand { get; private set; } = null!; + public ICommand ChoosePrivateCommand { get; private set; } = null!; + public ICommand CancelYouTubeCommand { get; private set; } = null!; + public ICommand StartYouTubeCommand { get; private set; } = null!; + public ICommand StopYouTubeCommand { get; private set; } = null!; + + public ICommand OpenRecordCommand { get; private set; } = null!; + public ICommand StartRecordingCommand { get; private set; } = null!; + public ICommand CancelRecordCommand { get; private set; } = null!; + public ICommand StopRecordingCommand { get; private set; } = null!; private void InitializeActivityCommands() { - SelectBuildActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Build)); - SelectYouTubeActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.YouTube)); - SelectRecordActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Record)); + SelectBuildCommand = new RelayCommand(_ => RaiseSlotStates(), _ => IsBuildScenesEnabled); + OpenYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.ChoosePrivacy)); + ChooseLiveCommand = new RelayCommand(_ => { _podcastLive = false; SetYouTubeStep(YouTubeSlotState.Armed); }); + ChoosePrivateCommand = new RelayCommand(_ => { _podcastLive = true; SetYouTubeStep(YouTubeSlotState.Armed); }); + CancelYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.Idle)); - ChooseLiveModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Live), _ => ShowYouTubeModeChoices); - ChoosePrivateModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Private), _ => ShowYouTubeModeChoices); - - // Start is its own press: the last chance to say "you're about to be live". // Signed out still routes to sign-in rather than sitting dead (no-dead-ends rule). - StartChosenStreamCommand = new RelayCommand(_ => + StartYouTubeCommand = new RelayCommand(_ => { if (!IsConnected) { _ = SignInAsync(); return; } - if (_streamMode == StreamMode.Private) BeginTestStream(); - else StartSession(); - }, _ => ShowYouTubeStart); + if (_podcastLive) BeginTestStream(); else StartSession(); + SetYouTubeStep(YouTubeSlotState.Streaming); + }); + + StopYouTubeCommand = new RelayCommand(_ => + { + StopStream(); + SetYouTubeStep(YouTubeSlotState.Idle); + }); + + OpenRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Armed)); + StartRecordingCommand = new RelayCommand(_ => + { + BeginRecordOnly(); + SetRecordStep(RecordSlotState.Recording); + }); + CancelRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Idle)); + StopRecordingCommand = new RelayCommand(_ => + { + StopStream(); + SetRecordStep(RecordSlotState.Idle); + }); } - /// Switches activity. Build is the only one that edits; the other two take the layers away - /// but leave the button lit, so the creator can see where they are and step back. - internal void SelectActivity(TopActivity activity) + private void SetYouTubeStep(YouTubeSlotState step) { - _activity = activity; - OnPropertyChanged(nameof(Activity)); - OnPropertyChanged(nameof(IsBuildActivity)); - OnPropertyChanged(nameof(IsYouTubeActivity)); - OnPropertyChanged(nameof(IsRecordActivity)); - - EditingActive = activity == TopActivity.Build; - - // The underlying outputs follow the activity, so the existing start commands and the - // record/live paths agree with what the bar is showing. - RecordPillOn = activity == TopActivity.Record; - OnAirPillOn = activity == TopActivity.YouTube; - _streamMode = StreamMode.None; // the YouTube flow always restarts at the choice - - RaiseActivityStage(); + _youTubeStep = step; + RaiseSlotStates(); } - private void SetStreamMode(StreamMode mode) + private void SetRecordStep(RecordSlotState step) { - _streamMode = mode; - OnPropertyChanged(nameof(IsLiveModeChosen)); - OnPropertyChanged(nameof(IsPrivateModeChosen)); - RaiseActivityStage(); + _recordStep = step; + RaiseSlotStates(); } - internal void RaiseActivityStage() + /// One place that announces everything the three slots show, so no caller can forget a flag. + /// + internal void RaiseSlotStates() { - OnPropertyChanged(nameof(ShowYouTubeModeChoices)); - OnPropertyChanged(nameof(ShowYouTubeStart)); - OnPropertyChanged(nameof(ShowYouTubeStop)); - OnPropertyChanged(nameof(ShowRecordStart)); - OnPropertyChanged(nameof(ShowRecordStop)); + OnPropertyChanged(nameof(IsBuildScenesEnabled)); + OnPropertyChanged(nameof(BuildScenesTooltip)); + + OnPropertyChanged(nameof(YouTubeStep)); + OnPropertyChanged(nameof(IsYouTubeIdle)); + OnPropertyChanged(nameof(IsYouTubeChoosingPrivacy)); + OnPropertyChanged(nameof(IsYouTubeArmed)); + OnPropertyChanged(nameof(IsYouTubeStreaming)); + OnPropertyChanged(nameof(IsLiveChosen)); + OnPropertyChanged(nameof(IsPrivateChosen)); + OnPropertyChanged(nameof(YouTubeArmLabel)); + OnPropertyChanged(nameof(YouTubeStopLabel)); + + OnPropertyChanged(nameof(RecordStep)); + OnPropertyChanged(nameof(IsRecordIdle)); + OnPropertyChanged(nameof(IsRecordArmed)); + OnPropertyChanged(nameof(IsRecordRecording)); + OnPropertyChanged(nameof(RecordElapsedLabel)); + + // Build Scenes is the edit surface, so the lock has to reach editing too. + EditingActive = IsBuildScenesEnabled; } } diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 4c1440d..037f29d 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -145,7 +145,18 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW `FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs` - Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height -- **The top bar has ONE decision (creator ruling 2026-11-12): `[ Build Scenes ][ Record / Go Live ]`.** +- **The top bar is THREE FIXED SLOTS (creator ruling 2026-11-13): `[Build Scenes] [YouTube] [Record]`.** + The bar's shape never changes — every transition happens INSIDE one slot, so neighbouring buttons cannot + move. Attempts that added completion states as siblings (`Live`, `Private`, `Stop`) read as five + ungrouped toolbar items and pushed Record around; that is the mistake this replaces. + Slot flows (`MainViewModel.TopBar.cs`, `YouTubeSlotState` / `RecordSlotState`): + - **YouTube:** Idle → ChoosePrivacy (`Live | Private | ✕`) → Armed (`Start Live|Start Private | ✕`, amber + border because the next click goes on air) → Streaming (`Stop Live|Stop Private`). + - **Record:** Idle → Armed (`Start Recording | ✕`) → Recording (`● REC hh:mm:ss` + `Stop`). + - **Build Scenes** is enabled only while BOTH action slots are Idle; otherwise it is disabled at 40% + opacity with the tooltip "Scene editing is locked while streaming or recording". + Arming and starting stay separate presses: Start is the last "are you ready?" moment. +- **Legacy note — superseded by the three-slot bar above:** Build Scenes ships highlighted and **is** edit mode — the standalone EDIT toggle is gone, so "which world am I in?" and "can I edit?" cannot disagree (`MainViewModel.TopBar.cs`, `BarWorld`). The segment leads the centre cluster and stays present in every state; clicking the highlighted half re-enters it, diff --git a/ytLive.Tests/TopBarActivityTests.cs b/ytLive.Tests/TopBarActivityTests.cs deleted file mode 100644 index cad59d9..0000000 --- a/ytLive.Tests/TopBarActivityTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.IO; -using Xunit; -using ytLive.ViewModels; - -namespace ytLive.Tests; - -/// -/// Three activities, always visible (creator, 2026-11-13): build, stream, record. The active one is lit -/// and runs its own flow in place; the other two stay on the bar and dim, so there is no state where the -/// creator cannot see where they are or how to step back. -/// -[Collection("MainViewModelLayoutPath")] -public class TopBarActivityTests -{ - private static MainViewModel NewVm(string db) - { - MainViewModel.LayoutPathOverride = db; - return new MainViewModel(); - } - - [Fact] - public void Three_activities_stay_on_the_bar_and_the_active_one_owns_its_flow() - { - var db = Path.Combine(Path.GetTempPath(), $"llamacasty-activity-{Guid.NewGuid():N}.db"); - try - { - var vm = NewVm(db); - - // Launch: Build is the activity and the only one editing. - Assert.Equal(TopActivity.Build, vm.Activity); - Assert.True(vm.IsBuildActivity); - Assert.True(vm.IsEditMode); - Assert.False(vm.IsRecording); - - // Record: its own flow becomes Start, and editing steps aside. - vm.SelectRecordActivityCommand.Execute(null); - Assert.True(vm.IsRecordActivity); - Assert.False(vm.IsEditMode); - Assert.True(vm.ShowRecordStart); - Assert.False(vm.ShowRecordStop); - Assert.Equal("Start Recording", vm.RecordActionLabel); - - // Recording swaps the flow to Stop, and the elapsed label carries REC. - vm.IsRecording = true; - Assert.False(vm.ShowRecordStart); - Assert.True(vm.ShowRecordStop); - Assert.Equal("Stop Recording", vm.RecordActionLabel); - Assert.StartsWith("REC: ", vm.RecordElapsedLabel); - vm.IsRecording = false; - - // YouTube: choosing Live only ARMS it. Start is a second press — the "are you ready?" moment. - vm.SelectYouTubeActivityCommand.Execute(null); - Assert.True(vm.IsYouTubeActivity); - Assert.True(vm.ShowYouTubeModeChoices); - Assert.False(vm.ShowYouTubeStart); - - vm.ChooseLiveModeCommand.Execute(null); - Assert.True(vm.IsLiveModeChosen); - Assert.False(vm.ShowYouTubeModeChoices); - Assert.True(vm.ShowYouTubeStart); - - vm.ChoosePrivateModeCommand.Execute(null); - Assert.True(vm.IsPrivateModeChosen); - Assert.False(vm.IsLiveModeChosen); - - // Back to Build: editing returns, and the YouTube choice is forgotten, not carried. - vm.SelectBuildActivityCommand.Execute(null); - Assert.True(vm.IsBuildActivity); - Assert.True(vm.IsEditMode); - Assert.False(vm.ShowYouTubeModeChoices); - Assert.False(vm.ShowYouTubeStart); - } - finally - { - MainViewModel.LayoutPathOverride = null; - try { File.Delete(db); } catch (IOException) { } - } - } -} diff --git a/ytLive.Tests/TopBarModeTests.cs b/ytLive.Tests/TopBarModeTests.cs index d8d04d6..d16d91e 100644 --- a/ytLive.Tests/TopBarModeTests.cs +++ b/ytLive.Tests/TopBarModeTests.cs @@ -87,18 +87,16 @@ public sealed class TopBarModeTests // A local recording lives in the Record activity (2026-11-13): the bar keeps its three // buttons and the Record one carries REC + the clock + Stop, so the separate reality line // stays away — it would be a second set of the same facts. - vm.SelectRecordActivityCommand.Execute(null); - vm.IsRecording = true; - Assert.True(vm.IsRecordActivity); - Assert.True(vm.ShowRecordStop); + vm.OpenRecordCommand.Execute(null); + vm.StartRecordingCommand.Execute(null); + Assert.True(vm.IsRecordRecording); Assert.False(vm.RunningVisible); Assert.StartsWith("REC: ", vm.RecordElapsedLabel); - vm.IsRecording = false; + vm.StopRecordingCommand.Execute(null); - // A live stream is the YouTube activity's flow; the reality line pins the mode. - vm.SelectYouTubeActivityCommand.Execute(null); + // A live stream is the YouTube slot's flow; the reality line pins the mode. vm.StreamStatus = StreamStatus.Streaming; - Assert.True(vm.ShowYouTubeStop); + Assert.True(vm.RunningVisible); Assert.Equal("LIVE", vm.RunningText); Assert.Equal("#ff5451", vm.RunningDotBrush); vm.IsTestStream = true; diff --git a/ytLive.Tests/TopBarSlotTests.cs b/ytLive.Tests/TopBarSlotTests.cs new file mode 100644 index 0000000..6e16061 --- /dev/null +++ b/ytLive.Tests/TopBarSlotTests.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using Xunit; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// The bar is three FIXED SLOTS (creator, 2026-11-13). Each slot owns its own flow and mutates only its +/// own interior, so neighbouring buttons never move; and while either action slot is past Idle, Build +/// Scenes is locked. +/// +[Collection("MainViewModelLayoutPath")] +public class TopBarSlotTests +{ + [Fact] + public void Each_slot_runs_its_own_flow_in_place_and_build_locks_while_they_do() + { + var db = Path.Combine(Path.GetTempPath(), $"llamacasty-slots-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = db; + try + { + var vm = new MainViewModel(); + + // All three idle: YouTube and Record are single buttons, Build is live. + Assert.True(vm.IsYouTubeIdle); + Assert.True(vm.IsRecordIdle); + Assert.True(vm.IsBuildScenesEnabled); + Assert.Contains("Edit layers", vm.BuildScenesTooltip); + + // YouTube: idle -> choose privacy -> armed -> (start) streaming. + vm.OpenYouTubeCommand.Execute(null); + Assert.True(vm.IsYouTubeChoosingPrivacy); + Assert.False(vm.IsBuildScenesEnabled); + Assert.Contains("locked", vm.BuildScenesTooltip); + + vm.ChoosePrivateCommand.Execute(null); + Assert.True(vm.IsYouTubeArmed); + Assert.Equal("Start Private", vm.YouTubeArmLabel); + + vm.ChooseLiveCommand.Execute(null); // switching choice while armed is allowed + Assert.Equal("Start Live", vm.YouTubeArmLabel); + + // Cancel returns that slot to Idle — and Build unlocks because both slots are idle again. + vm.CancelYouTubeCommand.Execute(null); + Assert.True(vm.IsYouTubeIdle); + Assert.True(vm.IsBuildScenesEnabled); + + // Record: idle -> armed -> (start) recording, with the clock carrying REC. + vm.OpenRecordCommand.Execute(null); + Assert.True(vm.IsRecordArmed); + Assert.False(vm.IsBuildScenesEnabled); + + vm.StartRecordingCommand.Execute(null); + Assert.True(vm.IsRecordRecording); + Assert.StartsWith("REC: ", vm.RecordElapsedLabel); + + vm.StopRecordingCommand.Execute(null); + Assert.True(vm.IsRecordIdle); + Assert.True(vm.IsBuildScenesEnabled); + } + finally + { + MainViewModel.LayoutPathOverride = null; + try { File.Delete(db); } catch (IOException) { } + } + } +} diff --git a/ytLive.Tests/UiShellTests.cs b/ytLive.Tests/UiShellTests.cs index ff9662f..9614eda 100644 --- a/ytLive.Tests/UiShellTests.cs +++ b/ytLive.Tests/UiShellTests.cs @@ -32,8 +32,8 @@ public class UiShellTests Assert.Contains("Text=\"Build Scenes\"", topBar); Assert.Contains("Text=\"YouTube\"", topBar); Assert.Contains("Text=\"Record\"", topBar); - Assert.Contains("Tag=\"{Binding IsBuildActivity}\"", topBar); - Assert.Contains("Tag=\"{Binding IsYouTubeActivity}\"", topBar); + Assert.Contains("IsEnabled=\"{Binding IsBuildScenesEnabled}\"", topBar); + Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar); Assert.DoesNotContain("x:Name=\"EditBody\"", topBar); // 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output @@ -51,8 +51,8 @@ public class UiShellTests // 2c. The output stage: two labelled switches and the exclusivity promise beside them. Assert.Contains("Text=\"YouTube\"", topBar); - Assert.Contains("Command=\"{Binding SelectYouTubeActivityCommand}\"", topBar); - Assert.Contains("Command=\"{Binding SelectRecordActivityCommand}\"", topBar); + Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar); + Assert.Contains("Command=\"{Binding OpenRecordCommand}\"", topBar); // 2d. The bar carries NO action button: the output line is the bar's whole middle // (creator ruling 2026-11-12 — the action is placed elsewhere, on instruction).