Files
LlamaCasty/ytLive.Tests/AlertLayerVideoTests.cs
T
gramps 7b940b6a5a fix(alerts): AlertOverlayLayer marshals the chat-poller seam to the UI thread
Live test session proved the native alert box DID play (the alert ring was the
only source in the mix: micLevel/loopLevel 0.000 while peakMix went
0.375->0.733->0.891 after the sim injections) but the app crashed at
11:22:01.301 the moment a REAL message round-tripped through the poller:

  System.ArgumentException: Must create DependencySource on same Thread as
  the DependencyObject
  at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()

OnMessageReceived ran RefreshAlertPreviews on the MTA poller thread and
stamped the WPF-bound VideoImageSource with a WriteableBitmap created there;
the binding engine's cross-thread re-bind killed the process. ChatOverlayLayer
already marshals this exact seam (Dispatcher.Invoke) - mirror it, guarded for
Application.Current null so the pure test seams still run inline.

Good Dog test:
AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread
calls the seam from a raw MTA Thread while the RealApp loop runs, then asserts
on the UI thread that the preview bitmap's Dispatcher is the App's (red on the
old seam, green on the fix). WPF cross-thread rule recorded in MyMistakes.md.

Full suite 320/320, clean build 0 warnings, scope-check green.
2026-09-26 11:28:36 -07:00

209 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Windows;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Audio;
namespace ytLive.Tests;
/// <summary>
/// TASK 47 Good Dog: the video-backed alert unit — the SAME good-dog single test
/// pinned on the RealApp STA host, driven through a FAKE <see cref="IAlertClipDecoder"/>
/// (the repo never runs a real codec in tests). It pins the whole lifecycle in one
/// pass: the custom clip path wins per-source over the built-in (resolution
/// seam), the decoder is created at play + torn down at drain, the ~300ms fade-in/out
/// alpha ENVELOPE rides the frames, the audio forwards at volume × fade (unity-add,
/// never ducked elsewhere), the EOF freeze-frame fades the tail out, the ticker strip
/// renders and scrolls during play and vanishes when idle — and the alert box always
/// returns to the transparent/idle state. The six-animation fallback stays covered by
/// AlertLayerTests.
/// </summary>
[Collection("RealApp")]
public sealed class AlertLayerVideoTests
{
private readonly RealAppHost _app;
public AlertLayerVideoTests(RealAppHost app) => _app = app;
[Fact]
public void AlertVideo_PlaysCustomClip_FadesReadTime_TickerScrolls_ForwardsScaledAudio_DrainsIdle()
{
_app.Run(Run);
}
private void Run()
{
const string clipPath = @"C:\fake\thank-you.mp4";
var fake = new FakeAlertClipDecoder();
var forwarded = new List<AudioSample>();
string? resolvedPath = null;
var box = new Source
{
Type = SourceType.AlertBox,
Name = "Stream Alerts",
Width = 600,
Height = 200,
AlertVideoPath = clipPath,
AlertUseDefaultVideo = false,
AlertShowTicker = true,
AlertVideoVolume = 0.8,
};
var scenes = new List<Scene> { new Scene { Name = "Main", Elements = { box } } };
var layer = new AlertOverlayLayer(
new AlertRenderer(),
clipDecoderFactory: (path, width, height) =>
{
resolvedPath = path;
Assert.Equal((int)box.Width, width);
Assert.Equal((int)box.Height, height);
return fake;
},
clipPathResolver: source =>
{
Assert.Same(box, source);
return source.AlertVideoPath;
},
audioSink: sample => forwarded.Add(sample));
layer.UpdateConfiguredBoxes(scenes);
var msg = new ChatMessage
{
Kind = ChatEventKind.SuperChat,
AuthorName = "Funder",
SuperChatDisplayString = "$10.00",
};
// ── Play: the custom path feeds a per-play decoder ──
layer.Enqueue(msg);
Assert.True(layer.IsPlaying);
Assert.Equal(1, fake.StartCount);
Assert.Equal(clipPath, resolvedPath);
fake.EmitFrame(600, 200);
// Audio inside the fade-in ramp (gain 0) must not leak into the mix.
fake.EmitAudio(new[] { 0.5f, -0.25f });
Assert.Empty(forwarded);
// ── Fade-in: alpha rides the 0.3s envelope measured against a real clip ──
Assert.Equal(0, AlphaAt(layer.RenderFrame(box)!)); // t=0
layer.Advance(0.15); // mid-ramp
Assert.Equal(127, AlphaAt(layer.RenderFrame(box)!));
fake.EmitAudio(new[] { 0.5f, -0.25f });
var midSample = Assert.Single(forwarded).Samples;
Assert.Equal(0.2, midSample[0], 2); // 0.8 × 0.5 mid-ramp
Assert.Equal(-0.1, midSample[1], 2);
forwarded.Clear();
layer.Advance(0.20); // past the ramp → opaque
Assert.Equal(255, AlphaAt(layer.RenderFrame(box)!));
fake.EmitAudio(new[] { 1.0f, 0.0f });
Assert.Equal(0.8, forwarded[^1].Samples[0], 2); // full fade: volume only
Assert.Equal(0.0, forwarded[^1].Samples[1], 2);
// ── Ticker: on during play, scrolls between frames ──
layer.Advance(1.0);
var tickerA = layer.AlertTickerFrame;
layer.Advance(0.1);
var tickerB = layer.AlertTickerFrame;
Assert.False(tickerA is null);
Assert.True(HasPixels(tickerA!), "the ticker must be visible while an alert plays");
Assert.False(tickerA!.BgraPixels.SequenceEqual(tickerB!.BgraPixels), "the ticker must scroll");
// ── EOF → freeze-frame fade-out over the same envelope, then drain ──
fake.End();
layer.Advance(0.15);
Assert.Equal(127, AlphaAt(layer.RenderFrame(box)!)); // halfway out
layer.Advance(0.20); // past the 0.3s tail
Assert.False(layer.IsPlaying);
Assert.Null(layer.RenderFrame(box));
Assert.Null(layer.AlertTickerFrame);
Assert.True(fake.DisposeCount == 1, "the per-play decoder must be torn down at drain");
}
private static byte AlphaAt(VideoFrame frame) => frame.BgraPixels[3];
private static bool HasPixels(VideoFrame frame) => frame.BgraPixels.Any(b => b != 0);
[Fact]
public void OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread()
{
Source? box = null;
AlertOverlayLayer? layer = null;
Exception? bgFailure = null;
var done = new ManualResetEventSlim();
_app.Run(() =>
{
box = new Source { Type = SourceType.AlertBox, Name = "Stream Alerts", Width = 600, Height = 200 };
var scenes = new List<Scene> { new Scene { Name = "Main", Elements = { box } } };
layer = new AlertOverlayLayer(new AlertRenderer());
var poller = new Thread(() =>
{
try
{
// The chat poller fires MessageReceived on its own (MTA) thread;
// the layer must marshal the preview writes to the UI thread —
// recreating the 2026-09-26 live crash ("Must create
// DependencySource on same Thread").
layer.OnMessageReceived(
new ChatMessage { Kind = ChatEventKind.NewMember, AuthorName = "Funder" }, scenes);
}
catch (Exception ex)
{
bgFailure = ex;
}
finally
{
done.Set();
}
});
poller.Start();
// Return to the message loop so the marshalled Ingest can run.
});
Assert.True(done.Wait(TimeSpan.FromSeconds(10)), "the poller call must not deadlock");
_app.Run(() =>
{
Assert.Null(bgFailure);
Assert.True(layer!.IsPlaying);
Assert.NotNull(box!.VideoImageSource);
Assert.Same(Application.Current.Dispatcher, box.VideoImageSource!.Dispatcher);
});
}
/// <summary>A decoder seam the test drives synchronously (no ffmpeg, no threads):
/// the layer calls Start/Stop/Dispose and the test feeds frames/audio/EOF.</summary>
private sealed class FakeAlertClipDecoder : IAlertClipDecoder
{
public int StartCount;
public int StopCount;
public int DisposeCount;
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioReady;
public event Action? Completed;
public void Start() => StartCount++;
public void Stop() => StopCount++;
public void Dispose() => DisposeCount++;
public void EmitFrame(int width, int height)
{
var pixels = new byte[width * height * 4];
for (var i = 3; i < pixels.Length; i += 4)
pixels[i] = 255; // opaque straight-alpha BGRA, blue body
FrameAvailable?.Invoke(new VideoFrame(width, height, pixels));
}
public void EmitAudio(float[] samples) => AudioReady?.Invoke(new AudioSample(samples, 48000, 2));
public void End() => Completed?.Invoke();
}
}