Files
LlamaCasty/ytLive.Tests/RealAppCollection.cs
T
gramps 70fcf89f9e feat(topbar): the recording lives in the pill — green pulse, REC clock, Stop Recording
Build 730 (creator):

- Left pill while recording: solid #2ECC71 dot pulsing 1.0 <-> 0.2 on the existing one-second timer, and
  "REC: hh:mm:ss" in Consolas so the digits cannot jitter the pill's width. Idle face unchanged.
- Right pill while recording: a stop square and "Stop Recording"; clicking it runs the normal stop, which
  halts and resets the clock and returns the pair to "Local MP4" / "Start Recording".
- The separate reality line (dot + REC/LIVE + elapsed + End) is now LIVE-ONLY: a recording would otherwise
  print the same three facts twice. RunningVisible is IsLive, and ShowOutputStage no longer hides itself
  for a recording (it must be on screen to carry the clock and the stop).

Two rules collided and are now separated rather than merged: a creator's Stop on a recording restores the
Local MP4 pair (build 730), while a pipeline FAILURE still clears both pills (the 2026-09-01 zombie rule).
StopStream(afterFailure:) carries the difference, and the pump-failure path passes it.

RealAppHost.Run was rethrowing with `throw failure;`, which destroyed the original stack — a failing
assertion inside the action reported the wrapper's line instead of its own. ExceptionDispatchInfo now
preserves it; that is how the two failures above were located in one pass instead of five.

680 pass (one audio-pipe instance flake under full-suite load, passes alone), 0 warnings.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 16:52:36 -08:00

112 lines
3.5 KiB
C#

using System;
using System.Threading;
using System.Windows.Threading;
using Xunit;
namespace ytLive.Tests;
/// <summary>
/// Tests that spin up the real WPF App need exactly one Application instance per
/// AppDomain (WPF enforces it) — so they share this serial collection and a
/// single App created on one dedicated STA thread. Marshal test bodies onto it
/// via <see cref="RealAppHost.Run"/>, never `new App()` per test.
/// </summary>
[CollectionDefinition("RealApp", DisableParallelization = true)]
public sealed class RealAppCollection : ICollectionFixture<RealAppHost>
{
}
/// <summary>Owns the one-and-only WPF App on a dedicated STA thread.</summary>
public sealed class RealAppHost : IDisposable
{
private readonly Thread _thread;
private readonly Dispatcher _dispatcher;
public RealAppHost()
{
Exception? boot = null;
Dispatcher? dispatcher = null;
_thread = new Thread(() =>
{
try
{
var app = new App();
app.InitializeComponent();
dispatcher = Dispatcher.CurrentDispatcher;
Dispatcher.Run();
}
catch (Exception ex)
{
boot = ex;
}
});
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
while (dispatcher == null && boot == null)
Thread.Sleep(5);
if (boot != null)
throw new Xunit.Sdk.XunitException("Real WPF App failed to start: " + boot);
_dispatcher = dispatcher!;
}
/// <summary>Runs <paramref name="action"/> on the App's STA thread and rethrows any failure.</summary>
public void Run(Action action)
{
Exception? failure = null;
_dispatcher.Invoke(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
});
// Rethrow preserving the ORIGINAL stack: `throw failure;` reset it to this line, which hid
// which assertion actually failed inside the passed action.
if (failure != null)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
}
/// <summary>Awaits <paramref name="action"/> on the App's STA thread and rethrows
/// any failure.
/// <para>A test that drives the frame pump MUST use this and <c>await</c> — never
/// <c>Run()</c> around a blocking wait. <see cref="Run"/> occupies the one STA
/// thread this collection shares, so a blocking call inside it deadlocks the pump
/// against the dispatcher and hangs the entire run with no output: the frame pump's
/// <c>StartAsync</c> needs to keep pumping while the test waits for a frame. Awaiting
/// yields the thread instead of holding it.</para></summary>
public async Task RunAsync(Func<Task> action)
{
Exception? failure = null;
await _dispatcher.InvokeAsync(async () =>
{
try
{
await action();
}
catch (Exception ex)
{
failure = ex;
}
});
if (failure != null)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
}
public void Dispose()
{
try
{
_dispatcher.InvokeShutdown();
}
catch
{
// The App never ran its message loop — shutdown is best-effort.
}
_thread.Join(2000);
}
}