Creator went live on the Debug build while the Release build was recording and got an empty
title/description, with the Stream Info tab filled in on screen. Both processes write to the same
profile: `BroadcastForm` is loaded ONCE at startup and each keystroke persists straight to the store,
so the instance that opened the dialog was holding the form as it was when it launched — before the
other instance saved those fields. The dialog copied from that stale copy.
`CreateGoLiveDialog` (new, extracted so it can be tested without showing a window) calls
`BroadcastForm.Load()` before composing the dialog. That is what the form's own doc comment always
claimed ("startup / Go Live refresh") but the refresh was never wired. It cannot lose an edit: every
keystroke is already persisted.
The dialog also logs `Go Live dialog: title='…' description=N chars`, so the next report of "the
dialog was blank" says immediately whether the values reached the dialog or never left the store.
Test: `BroadcastPullOutTests` writes metadata through a second `LayoutStore` handle (the other
instance) after the VM has loaded and asserts the dialog carries it. 572 green, 0 warnings.
255 lines
11 KiB
C#
255 lines
11 KiB
C#
using System;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Text.Json;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK: broadcast metadata pull-out + launch geometry. One integration slice:
|
|
/// (1) pull-out edits persist immediately to the Settings store and re-load,
|
|
/// (2) a real MainWindow restores saved size/position, clamping off-screen or
|
|
/// below-minimum values into the primary work area, (3) UpdateBroadcast PATCHes
|
|
/// liveBroadcasts?part=snippet,status with the full editable field set.
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class BroadcastPullOutTests
|
|
{
|
|
public BroadcastPullOutTests(RealAppHost app) => _app = app;
|
|
|
|
private readonly RealAppHost _app;
|
|
|
|
[Fact]
|
|
public void Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote()
|
|
{
|
|
_app.Run(Run);
|
|
}
|
|
|
|
private sealed class StubHandler : HttpMessageHandler
|
|
{
|
|
public HttpRequestMessage? Request;
|
|
public string? Body;
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Request = request;
|
|
Body = request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("{\"id\":\"bc123\"}", System.Text.Encoding.UTF8, "application/json"),
|
|
});
|
|
}
|
|
}
|
|
|
|
private void Run()
|
|
{
|
|
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-pullout-{Guid.NewGuid():N}.db");
|
|
ViewModels.MainViewModel.LayoutPathOverride = tempDb;
|
|
MainWindow? window = default;
|
|
try
|
|
{
|
|
using (var schema = new LayoutStore(tempDb)) { }
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
|
|
var vm = new ViewModels.MainViewModel();
|
|
|
|
vm.BroadcastForm.Title = "Hello";
|
|
vm.BroadcastForm.Description = "World";
|
|
vm.BroadcastForm.TagsCsv = "a, b ,c";
|
|
vm.BroadcastForm.Visibility = "public";
|
|
vm.BroadcastForm.MadeForKids = true;
|
|
|
|
using (var reader = new LayoutStore(tempDb))
|
|
{
|
|
var meta = reader.LoadBroadcastMetadata();
|
|
Assert.Equal("Hello", meta.Title);
|
|
Assert.Equal("World", meta.Description);
|
|
Assert.Equal("a, b ,c", meta.TagsCsv);
|
|
Assert.Equal("public", meta.Visibility);
|
|
Assert.True(meta.MadeForKids);
|
|
}
|
|
|
|
vm.BroadcastForm.Load();
|
|
Assert.Equal("Hello", vm.BroadcastForm.Title);
|
|
|
|
// Go Live must read what the STORE holds, not this process's copy. Two instances share the
|
|
// real profile (Release recording while Debug goes live), and the other one's edits never
|
|
// reach this process's in-memory form — that is how the creator got an empty dialog while
|
|
// the Stream Info tab was filled in (2026-11-06).
|
|
using (var otherInstance = new LayoutStore(tempDb))
|
|
otherInstance.SaveBroadcastMetadata(new BroadcastMetadata
|
|
{
|
|
Title = "Written by the other instance",
|
|
Description = "And its description",
|
|
});
|
|
|
|
var dialog = vm.CreateGoLiveDialog();
|
|
Assert.Equal("Written by the other instance", dialog.StreamTitle);
|
|
Assert.Equal("And its description", dialog.StreamDescription);
|
|
|
|
Assert.False(vm.BroadcastForm.IsDrawerOpen);
|
|
vm.BroadcastForm.ToggleDrawerCommand.Execute(null);
|
|
Assert.True(vm.BroadcastForm.IsDrawerOpen);
|
|
vm.BroadcastForm.ToggleDrawerCommand.Execute(null);
|
|
Assert.False(vm.BroadcastForm.IsDrawerOpen);
|
|
vm.BroadcastForm.ToggleDrawerCommand.Execute(null);
|
|
Assert.True(vm.BroadcastForm.IsDrawerOpen);
|
|
vm.BroadcastForm.CloseDrawerCommand.Execute(null);
|
|
Assert.False(vm.BroadcastForm.IsDrawerOpen);
|
|
|
|
using (var writer = new LayoutStore(tempDb))
|
|
writer.SaveWindowState(100, 50, 1500, 900, "Normal");
|
|
|
|
var work = System.Windows.SystemParameters.WorkArea;
|
|
|
|
window = new MainWindow();
|
|
Assert.Equal(1500, window.Width);
|
|
Assert.Equal(900, window.Height);
|
|
// Launch POSITION is derived, never restored (2026-10-04). The 100,50 saved
|
|
// above is deliberately ignored: restoring it is what let the window come
|
|
// back below the fold, which is the whole reason this behaviour changed.
|
|
Assert.Equal(Math.Min(work.Left + 75, work.Right - 1500), window.Left);
|
|
Assert.Equal(Math.Min(work.Top + 75, work.Bottom - 900), window.Top);
|
|
|
|
window.Close();
|
|
window = null;
|
|
|
|
using (var writer = new LayoutStore(tempDb))
|
|
writer.SaveWindowState(-99999, -99999, 800, 500, "Normal");
|
|
|
|
window = new MainWindow();
|
|
Assert.Equal(1366, window.Width); // saved 800 clamps up to MinWidth
|
|
Assert.Equal(768, window.Height); // saved 500 clamps up to MinHeight
|
|
// A wildly off-screen saved position (-99999) no longer influences placement
|
|
// at all — the old clamp tolerated Left as low as work.Left - Width + 120,
|
|
// i.e. the window could sit almost entirely off the left edge.
|
|
Assert.Equal(Math.Min(work.Left + 75, work.Right - window.Width), window.Left);
|
|
Assert.Equal(Math.Min(work.Top + 75, work.Bottom - window.Height), window.Top);
|
|
Assert.InRange(window.Left, work.Left, work.Right - window.Width);
|
|
Assert.InRange(window.Top, work.Top, work.Bottom - window.Height);
|
|
|
|
var auth = new YouTubeAuthService("test-client", "test-secret");
|
|
auth.SetSession(new YouTubeChannel
|
|
{
|
|
AccessToken = "token",
|
|
TokenExpiry = DateTime.UtcNow.AddHours(1),
|
|
});
|
|
var stub = new StubHandler();
|
|
var service = new YouTubeStreamService(auth, new HttpClient(stub));
|
|
|
|
var error = service.UpdateBroadcast("bc123", new BroadcastMetadata
|
|
{
|
|
Title = "T",
|
|
Description = "D",
|
|
TagsCsv = "x,y",
|
|
Visibility = "Public",
|
|
MadeForKids = true,
|
|
ScheduledStartTime = new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc),
|
|
}).GetAwaiter().GetResult();
|
|
|
|
Assert.Null(error);
|
|
Assert.NotNull(stub.Request);
|
|
Assert.Equal(HttpMethod.Put, stub.Request!.Method);
|
|
Assert.Equal("https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status",
|
|
stub.Request.RequestUri!.ToString());
|
|
|
|
using var doc = JsonDocument.Parse(stub.Body!);
|
|
var root = doc.RootElement;
|
|
Assert.Equal("bc123", root.GetProperty("id").GetString());
|
|
Assert.Equal("T", root.GetProperty("snippet").GetProperty("title").GetString());
|
|
Assert.Equal(2, root.GetProperty("snippet").GetProperty("tags").GetArrayLength());
|
|
Assert.Equal("public", root.GetProperty("status").GetProperty("privacyStatus").GetString());
|
|
Assert.True(root.GetProperty("status").GetProperty("selfDeclaredMadeForKids").GetBoolean());
|
|
Assert.Equal(new DateTime(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc).ToString("o"),
|
|
root.GetProperty("snippet").GetProperty("scheduledStartTime").GetString());
|
|
|
|
// --- Diff + close drawer tests ---
|
|
|
|
var auth2 = new YouTubeAuthService("test-client", "test-secret");
|
|
auth2.SetSession(new YouTubeChannel
|
|
{
|
|
AccessToken = "token2",
|
|
TokenExpiry = DateTime.UtcNow.AddHours(1),
|
|
});
|
|
var stub2 = new StubHandler();
|
|
var service2 = new YouTubeStreamService(auth2, new HttpClient(stub2));
|
|
|
|
string? broadcastId = "bc456";
|
|
|
|
// --- Offline path (isLive = false): saves locally, never pushes API ---
|
|
var formOffline = new LiveBroadcastFormViewModel(
|
|
() => new LayoutStore(tempDb),
|
|
() => service2,
|
|
() => broadcastId,
|
|
() => false);
|
|
|
|
formOffline.Load();
|
|
Assert.False(formOffline.IsDrawerOpen);
|
|
|
|
// No edits → stays open, no API call
|
|
formOffline.IsDrawerOpen = true;
|
|
formOffline.UpdateBroadcastCommand.Execute(null);
|
|
Assert.Null(stub2.Request);
|
|
Assert.False(formOffline.IsDrawerOpen);
|
|
|
|
// Edit Title → closes drawer, still no API call (offline = local save only)
|
|
stub2.Request = null;
|
|
formOffline.Title = "Changed offline";
|
|
formOffline.IsDrawerOpen = true;
|
|
formOffline.UpdateBroadcastCommand.Execute(null);
|
|
Assert.Null(stub2.Request);
|
|
Assert.False(formOffline.IsDrawerOpen);
|
|
|
|
// Re-load → the offline edit persisted to the store
|
|
formOffline.Load();
|
|
Assert.Equal("Changed offline", formOffline.Title);
|
|
|
|
// --- Live path (isLive = true): pushes to API ---
|
|
var formLive = new LiveBroadcastFormViewModel(
|
|
() => new LayoutStore(tempDb),
|
|
() => service2,
|
|
() => broadcastId,
|
|
() => true);
|
|
|
|
formLive.Load();
|
|
formLive.IsDrawerOpen = true;
|
|
|
|
// No edits → "No changes to update", stays open
|
|
stub2.Request = null;
|
|
formLive.UpdateBroadcastCommand.Execute(null);
|
|
Assert.Null(stub2.Request);
|
|
Assert.Equal("No changes to update", formLive.UpdateError);
|
|
Assert.True(formLive.IsDrawerOpen);
|
|
|
|
// Edit Title → HTTP PUT fires, drawer closes
|
|
stub2.Request = null;
|
|
formLive.Title = "Live update";
|
|
formLive.UpdateBroadcastCommand.Execute(null);
|
|
Assert.NotNull(stub2.Request);
|
|
Assert.Equal(HttpMethod.Put, stub2.Request!.Method);
|
|
Assert.Null(formLive.UpdateError);
|
|
Assert.False(formLive.IsDrawerOpen);
|
|
|
|
// Re-seed snapshot via Load, then edit again → second upload fires
|
|
stub2.Request = null;
|
|
formLive.Load();
|
|
formLive.Description = "Live desc";
|
|
formLive.UpdateBroadcastCommand.Execute(null);
|
|
Assert.NotNull(stub2.Request);
|
|
Assert.Null(formLive.UpdateError);
|
|
Assert.False(formLive.IsDrawerOpen);
|
|
}
|
|
finally
|
|
{
|
|
window?.Close();
|
|
ViewModels.MainViewModel.LayoutPathOverride = null;
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|