feat(ui): TASK 42 top bar redesign — one Record-or-Stream surface (2026-09-22)
The bar now renders ONE surface from the FIRST decision the creator makes — Record or Stream — with each world being the whole bar. Creator directive: "I wrote the fucking thing and I still can't figure-out how to do stuff", "forget this one dog plan bullshit". OBS/streaming-tool reference for the one-surface paradigm: https://obsproject.com (single mode toggle + context actions) — cited per spin-guard habit; the go-live/test pattern follows CEV (https://cev-desktop.aSean.xyz) precedent. - Mode: single segmented REC|ON-AIR switch (SegmentToggle/SegmentLabel in Themes/Controls.xaml); radio-exclusive world selection, one tap to flip. - Record world: switch + Start Recording, zero YouTube identity. - Stream world: switch + Go Live + Test + account zone right (Sign In until connected, then avatar with Change Account/Logout context menu). - Test is a child of Stream: procs only stream-armed AND signed-in. - Running: one reality line "dot word elapsed" (REC/LIVE/TEST; green/red/ gold) + End; worlds + switch retire. - Gear moved up from bottom bar, ~3 wordmark letters past the brand, single click opens Settings/Bug/Feature/About menu (TASK 40 Unit B shipped early). - Fix folded in: BeginTestStream now arms OnAirPillOn explicitly (unarmed pump booted with zero encoder outputs -> "At least one output is required" forced-stop cascade from TASK 41's pipeline). - VM: world/reality props + RaiseTopBarModes() wired into StreamStatus, pill, IsRecording, IsTestStream, IsConnected setters. Doc: ai.md top-bar model, ViewModels/index.md, Controls/index.md, TASKS.md, HANDOFF.md, new TASKS/task-42-top-bar-redesign.md; task-40 note. Tests: TopBarModeTests.cs (new Good Dog), PillRadioTests + TestStreamTests gates updated. Build 0 warnings; full suite 314/315 — single abort is the pre-existing env-dependent RealMouseDrag test (Path of Exile 2 running)
This commit is contained in:
@@ -65,13 +65,13 @@ public sealed class PillRadioTests
|
||||
/// <summary>Good Dog (one integration test, creator ruling 2026-09-20): the ON-AIR
|
||||
/// pill is armable signed-out — intent may light while the account is offline (a
|
||||
/// greyed pill was the dead end the guard shipped to forbid). A front-branch on
|
||||
/// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR. The
|
||||
/// Start button face reads "Sign In" while the armed signed-out pill is lit, and
|
||||
/// flips back to "Start" the moment a session connects — the pill keeps its light
|
||||
/// either way. Signed-out ON-AIR Start routes to the sign-in; a signed-out go-live
|
||||
/// is the dead end the ruling forbids.</summary>
|
||||
/// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR.
|
||||
/// Since the top bar redesign, the Go face names the armed WORLD ("Go Live")
|
||||
/// rather than the account race — a signed-out Stream press routes into the
|
||||
/// sign-in inside StartSession, and the dedicated Sign In button appears on the
|
||||
/// right exactly when the stream sequence needs the account step.</summary>
|
||||
[Fact]
|
||||
public void SignedOut_OnAir_Pill_Stays_Lit_And_Start_Face_Is_SignIn()
|
||||
public void SignedOut_OnAir_Pill_Stays_Lit_And_Stream_World_Is_GoLive()
|
||||
{
|
||||
_app.Run(RunSignedOutOnAir);
|
||||
}
|
||||
@@ -86,6 +86,9 @@ public sealed class PillRadioTests
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
// The dev machine may hold a real saved OAuth session (loads synchronously
|
||||
// during the ctor), so the signed-out face is forced deterministically.
|
||||
vm.IsConnected = false;
|
||||
|
||||
// No sign-in: the ON-AIR pill MUST still be able to light — intent before
|
||||
// reality, armable signed-out (creator ruling 2026-09-20).
|
||||
@@ -93,16 +96,21 @@ public sealed class PillRadioTests
|
||||
Assert.True(vm.OnAirPillOn, "ON-AIR pill must be armable signed-out (greyed pill = dead end)");
|
||||
Assert.True(vm.CanStartSession);
|
||||
|
||||
// While armed signed-out the Start button wears the sign-in face, so a
|
||||
// signed-out start is never the dead-end go-live the ruling forbids.
|
||||
Assert.Equal("Sign In", vm.PrimaryStartButtonLabel);
|
||||
Assert.False(vm.ShowPrimaryStartButton && vm.CanStartSession && false,
|
||||
"sanity: face follows the pill, not the account race");
|
||||
// While armed signed-out the Go face names the Stream world ("Go Live");
|
||||
// the account step surfaces as the Sign In button on the right instead,
|
||||
// and a signed-out start routes into the sign-in (never a dead-end go-live).
|
||||
Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
|
||||
Assert.True(vm.ShowSignInButton);
|
||||
Assert.False(vm.ShowRecordAction);
|
||||
Assert.True(vm.ShowStreamActions);
|
||||
|
||||
// A session connects: the pill KEEPS its light and the face flips to Start.
|
||||
// A session connects: the pill KEEPS its light; the stream world's Sign In
|
||||
// retires and the avatar confirms the account.
|
||||
vm.IsConnected = true;
|
||||
Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in");
|
||||
Assert.Equal("Start", vm.PrimaryStartButtonLabel);
|
||||
Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
|
||||
Assert.False(vm.ShowSignInButton);
|
||||
Assert.True(vm.ShowAvatarButton);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -149,15 +149,20 @@ public sealed class TestStreamTests
|
||||
Assert.False(model.Ypp.IsDrawerOpen);
|
||||
Assert.False(model.TestSession.IsDrawerOpen);
|
||||
|
||||
// Test-button gate follows the sign-in state deterministically (the
|
||||
// initial state is environment-dependent — a dev machine may hold a real
|
||||
// saved OAuth session).
|
||||
// Test-button gate: Test is a child of Stream — it procs ONLY when the
|
||||
// ON-AIR world is armed AND signed in (title-gating ruling). The initial
|
||||
// sign-in state is environment-dependent (a dev machine may hold a real
|
||||
// saved OAuth session), so it is forced deterministically.
|
||||
model.IsConnected = false;
|
||||
Assert.False(model.CanStartTest);
|
||||
Assert.False(model.ShowTestButton);
|
||||
model.OnAirPillOn = true; // stream world
|
||||
model.IsConnected = true;
|
||||
Assert.True(model.CanStartTest);
|
||||
Assert.True(model.ShowTestButton);
|
||||
model.RecordPillOn = true; // back to the record world → Test retires
|
||||
Assert.False(model.CanStartTest);
|
||||
Assert.False(model.ShowTestButton);
|
||||
|
||||
model.TestSession.ToggleDrawerCommand.Execute(null);
|
||||
Assert.True(model.TestSession.IsDrawerOpen);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Top bar redesign — the bar is ONE surface rendered by the FIRST decision
|
||||
/// (Record or Stream). Each world is the whole bar: the Record world has no
|
||||
/// YouTube identity at all, the Stream world owns the account zone (Sign In or
|
||||
/// avatar) plus Test. The switch is a two-position gate, so "going back" is one
|
||||
/// tap on the other segment. While running, the switch retires and a single
|
||||
/// reality line (dot + word + elapsed) + End replace it.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class TopBarModeTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public TopBarModeTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void TopBar_Renders_Only_The_Armed_World()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-topbar-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
|
||||
// The dev machine may hold a real saved OAuth session (loads synchronously
|
||||
// during the ctor), so the sign-in state is forced deterministically.
|
||||
vm.IsConnected = false;
|
||||
|
||||
// Default (nothing armed) = the Record world: Go says "Start Recording",
|
||||
// no account zone, no Test — YouTube doesn't exist in this world.
|
||||
Assert.True(vm.ShowIdleWorld);
|
||||
Assert.True(vm.ShowRecordAction);
|
||||
Assert.False(vm.ShowStreamActions);
|
||||
Assert.False(vm.ShowSignInButton);
|
||||
Assert.False(vm.ShowAvatarButton);
|
||||
Assert.False(vm.ShowTestButton);
|
||||
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
|
||||
|
||||
// Arming ON-AIR switches the whole bar to the Stream world.
|
||||
vm.OnAirPillOn = true;
|
||||
Assert.False(vm.ShowRecordAction);
|
||||
Assert.True(vm.ShowStreamActions);
|
||||
|
||||
// Signed out: the account zone shows Sign In; no avatar; Test is gated
|
||||
// behind the sign-in (Test rides on Stream AND a connected account).
|
||||
Assert.True(vm.ShowSignInButton);
|
||||
Assert.False(vm.ShowAvatarButton);
|
||||
Assert.False(vm.ShowTestButton);
|
||||
Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
|
||||
|
||||
// Connected: avatar + Test join the Stream world; Sign In retires.
|
||||
vm.IsConnected = true;
|
||||
Assert.False(vm.ShowSignInButton);
|
||||
Assert.True(vm.ShowAvatarButton);
|
||||
Assert.True(vm.ShowTestButton);
|
||||
|
||||
// Going back is one tap: flipping REC returns the bar to the Record world.
|
||||
vm.RecordPillOn = true;
|
||||
Assert.False(vm.ShowStreamActions);
|
||||
Assert.True(vm.ShowRecordAction);
|
||||
Assert.False(vm.ShowAvatarButton);
|
||||
Assert.False(vm.ShowTestButton);
|
||||
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
|
||||
|
||||
// Running retires the switch and worlds; the reality line pins the mode.
|
||||
vm.RecordPillOn = false;
|
||||
vm.OnAirPillOn = true;
|
||||
vm.IsRecording = true;
|
||||
Assert.False(vm.ShowIdleWorld);
|
||||
Assert.True(vm.RunningVisible);
|
||||
Assert.Equal("REC", vm.RunningText);
|
||||
Assert.Equal("#22c55e", vm.RunningDotBrush);
|
||||
vm.IsRecording = false;
|
||||
vm.StreamStatus = StreamStatus.Streaming;
|
||||
Assert.Equal("LIVE", vm.RunningText);
|
||||
Assert.Equal("#e94560", vm.RunningDotBrush);
|
||||
vm.IsTestStream = true;
|
||||
Assert.Equal("TEST", vm.RunningText);
|
||||
Assert.Equal("#9c6f1c", vm.RunningDotBrush);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user