From 69f08a478e2bb3ec74c14e469d01af1830427dca Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 13 Nov 2026 12:20:11 -0800 Subject: [PATCH] =?UTF-8?q?feat(topbar):=20three=20fixed=20slots=20?= =?UTF-8?q?=E2=80=94=20every=20state=20changes=20inside=20its=20own=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar items". Correct — completion states were being added to the BAR instead of to a SLOT. Now the bar is exactly three slots, and the bar's shape never changes: [ Build Scenes ] [ YouTube ] [ Record ] YouTube: ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air) -> [■ Stop Live] Record: ◉ Record -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop] Build: enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip "Scene editing is locked while streaming or recording" State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else. This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle). 679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines). Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being replaced; TopBarSlotTests is its successor. --- Controls/TopBar.xaml | 231 +++++++++++------- MainWindow.xaml.cs | 16 +- .../MainViewModel.Streaming.Operations.cs | 4 +- ViewModels/MainViewModel.Streaming.cs | 8 +- ViewModels/MainViewModel.TopBar.cs | 204 ++++++++++------ docs/ai/ui-surface.md | 13 +- ytLive.Tests/TopBarActivityTests.cs | 80 ------ ytLive.Tests/TopBarModeTests.cs | 14 +- ytLive.Tests/TopBarSlotTests.cs | 68 ++++++ ytLive.Tests/UiShellTests.cs | 8 +- 10 files changed, 373 insertions(+), 273 deletions(-) delete mode 100644 ytLive.Tests/TopBarActivityTests.cs create mode 100644 ytLive.Tests/TopBarSlotTests.cs 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).