e69db4d26c
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)
194 lines
7.9 KiB
C#
194 lines
7.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Data.Sqlite;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 41 Good Dog: Test Stream mode. Two sections under one [Fact]:
|
|
/// (1) the Test drawer's chat tooling through the coordinator VM (pure services, fake
|
|
/// HTTP — no network, no WPF): BeginTest opens the dock, Mock Chat Input emits a real
|
|
/// liveChat/messages.insert (POST body asserted), simulated member / SuperChat /
|
|
/// subscriber events arrive through the MessageReceived seam marked IsSimulated,
|
|
/// End asks the app to stop, EndTestSilently clears the session; (2) a real MainWindow
|
|
/// (temp-DB + RealAppHost, mirrors YppPullOutTests) enforces three-way one-open-at-a-time
|
|
/// drawer exclusivity and the Test-button sign-in gate.
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class TestStreamTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public TestStreamTests(RealAppHost app) => _app = app;
|
|
|
|
private sealed class RecordingHandler : HttpMessageHandler
|
|
{
|
|
public string? LastBody;
|
|
public string ResponseBody = """{"id":"msg-1"}""";
|
|
public readonly List<string> Requests = new();
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Requests.Add($"{request.Method.Method} {request.RequestUri}");
|
|
LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
|
|
});
|
|
}
|
|
}
|
|
|
|
private static YouTubeAuthService CreateAuthed()
|
|
{
|
|
var auth = new YouTubeAuthService("test-id", "test-secret");
|
|
auth.SetSession(new YouTubeChannel
|
|
{
|
|
AccessToken = "acc-123",
|
|
TokenExpiry = DateTime.UtcNow.AddHours(1),
|
|
});
|
|
return auth;
|
|
}
|
|
|
|
[Fact]
|
|
public void TestStream_DockTooling_And_ThreeWayDrawerExclusivity()
|
|
{
|
|
RunCoordinator();
|
|
_app.Run(RunWindow);
|
|
}
|
|
|
|
private void RunCoordinator()
|
|
{
|
|
var handler = new RecordingHandler();
|
|
var stream = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
|
|
var chat = new YouTubeChatService(CreateAuthed());
|
|
var received = new List<ChatMessage>();
|
|
chat.MessageReceived += received.Add;
|
|
|
|
var ended = false;
|
|
var vm = new TestSessionViewModel(stream, chat, onEndStream: () => ended = true);
|
|
|
|
Assert.False(vm.IsTestSession, "idle until a test broadcast hands in");
|
|
Assert.False(vm.IsDrawerOpen);
|
|
Assert.False(vm.CanSendTestMessage);
|
|
|
|
// The boot path: MainViewModel opens the dock once the liveChatId resolves.
|
|
vm.BeginTest("BC-TEST", "Cg0KC0NOTIFTEST");
|
|
Assert.True(vm.IsTestSession);
|
|
Assert.True(vm.IsDrawerOpen, "BeginTest opens the dock so it's immediately usable");
|
|
Assert.Contains("Cg0KC0NOTIFTEST", vm.ChatIdLine);
|
|
Assert.False(vm.CanSendTestMessage, "no draft text yet — an empty send is disabled");
|
|
|
|
// Mock Chat Input → a REAL liveChat/messages.insert (POST body asserted).
|
|
vm.SendMessageText = "hello test chat";
|
|
Assert.True(vm.CanSendTestMessage);
|
|
vm.SendTestMessageAsync().GetAwaiter().GetResult();
|
|
Assert.NotNull(handler.LastBody);
|
|
Assert.Contains("\"liveChatId\":\"Cg0KC0NOTIFTEST\"", handler.LastBody);
|
|
Assert.Contains("\"messageText\":\"hello test chat\"", handler.LastBody);
|
|
Assert.Empty(received);
|
|
Assert.Contains("hello test chat", vm.LastEventText);
|
|
|
|
// Simulated events arrive through the poller's MessageReceived seam,
|
|
// marked simulated so the buffer stays honest (they never reach YouTube).
|
|
vm.SimulateMemberCommand.Execute(null);
|
|
vm.SimulateSuperChatCommand.Execute(null);
|
|
vm.SimulateSubscriberCommand.Execute(null);
|
|
Assert.Equal(3, received.Count);
|
|
Assert.All(received, m => Assert.True(m.IsSimulated));
|
|
|
|
var member = received[0];
|
|
Assert.True(member.IsMember);
|
|
Assert.Equal("New member", member.MembershipLevel);
|
|
|
|
var superChat = received[1];
|
|
Assert.True(superChat.IsSuperChat);
|
|
Assert.Equal(5.00, superChat.SuperChatAmount, 2);
|
|
Assert.Equal("$", superChat.SuperChatCurrency);
|
|
|
|
var subscriber = received[2];
|
|
Assert.False(subscriber.IsMember);
|
|
Assert.False(subscriber.IsSuperChat);
|
|
Assert.Contains("subscriber", subscriber.Message, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// The End Test button hands back to the app (MainViewModel.StopStream).
|
|
vm.EndTestCommand.Execute(null);
|
|
Assert.True(ended);
|
|
|
|
// The app's stop path forgets the session — idempotent.
|
|
vm.EndTestSilently();
|
|
vm.EndTestSilently();
|
|
Assert.False(vm.IsTestSession);
|
|
Assert.False(vm.CanSendTestMessage);
|
|
}
|
|
|
|
private void RunWindow()
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
MainWindow? window = default;
|
|
try
|
|
{
|
|
using (var schema = new LayoutStore(tempDb)) { }
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
// ─── Section 2: real window — three-way exclusivity + gate ───
|
|
window = new MainWindow();
|
|
var model = (MainViewModel)window.DataContext;
|
|
Assert.False(model.BroadcastForm.IsDrawerOpen);
|
|
Assert.False(model.Ypp.IsDrawerOpen);
|
|
Assert.False(model.TestSession.IsDrawerOpen);
|
|
|
|
// 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);
|
|
Assert.False(model.BroadcastForm.IsDrawerOpen, "opening TEST must leave Stream Settings closed");
|
|
Assert.False(model.Ypp.IsDrawerOpen, "opening TEST must leave YPP closed");
|
|
|
|
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
|
|
Assert.True(model.BroadcastForm.IsDrawerOpen);
|
|
Assert.False(model.TestSession.IsDrawerOpen, "opening Stream Settings must close TEST");
|
|
|
|
model.Ypp.ToggleDrawerCommand.Execute(null);
|
|
Assert.True(model.Ypp.IsDrawerOpen);
|
|
Assert.False(model.TestSession.IsDrawerOpen, "opening YPP must leave TEST closed");
|
|
Assert.False(model.BroadcastForm.IsDrawerOpen);
|
|
|
|
model.TestSession.ToggleDrawerCommand.Execute(null);
|
|
Assert.True(model.TestSession.IsDrawerOpen);
|
|
model.TestSession.CloseDrawerCommand.Execute(null);
|
|
Assert.False(model.TestSession.IsDrawerOpen);
|
|
}
|
|
finally
|
|
{
|
|
window?.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
} |