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; /// /// 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. /// [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 Requests = new(); protected override Task 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(); 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 */ } } } }