Files
gramps ecb329e578 fix(ui): drawers close on any click outside the rail — TEST was the missing third
Creator: 'when the test slide-out is active, then any click off the div should
close the div — this behaviour applies to all tabs, not just TEST.'

MainWindow.Window_PreviewMouseLeftButtonDown already closed the Stream Settings
and YPP drawers on any click outside TextPullOutHost, but its close list skipped
TestSession — so the TEST drawer never dismissed on an outside click.

Fix: run TestSession.CloseDrawerCommand in the same outside-click branch.
All three drawers share the rail host, so the containment check is unchanged.

Good Dog: ONE integration test — the existing TestStream window section now
raises a window-root PreviewMouseLeftButtonDown (source = window => outside the
rail; deterministic, no OS mouse) and asserts the TEST and Stream Settings
drawers both close. Gate: clean build 0 warnings, full vstest 318/318.
2026-09-25 08:18:25 -07:00

215 lines
9.2 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 System.Windows;
using System.Windows.Input;
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.Contains("\"type\":\"textMessageEvent\"", 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);
// Any left-click OUTSIDE the drawer rail collapses whichever drawer is
// open (MainWindow.Window_PreviewMouseLeftButtonDown) — the TEST drawer
// was the missing third; Stream Settings + YPP already behaved this way.
// A raised PreviewMouseLeftButtonDown on the window root lands "outside",
// deterministically, with no OS mouse input involved.
var outsideClick = new MouseButtonEventArgs(Mouse.PrimaryDevice, Environment.TickCount, MouseButton.Left)
{
RoutedEvent = UIElement.PreviewMouseLeftButtonDownEvent,
};
model.TestSession.ToggleDrawerCommand.Execute(null);
Assert.True(model.TestSession.IsDrawerOpen);
window.RaiseEvent(outsideClick);
Assert.False(model.TestSession.IsDrawerOpen, "TEST drawer must close on an outside click");
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
Assert.True(model.BroadcastForm.IsDrawerOpen);
window.RaiseEvent(outsideClick);
Assert.False(model.BroadcastForm.IsDrawerOpen, "Stream Settings drawer must close on an outside click");
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}