Files
LlamaCasty/ytLive.Tests/AlertLayerVideoTests.cs
gramps 80038ff152 fix(alerts): silent decoder falls back to the six animations after a 1s no-frame grace
Routing and ruling both good: 'procs in the chat windows but nothing in the alert box'
with zero exceptions — every failure stage was silent by design (Debug.WriteLine-only
preview catch, bare catch{} in RunAsync firing Completed regardless). A decode run that
starts but yields neither a frame nor an audio chunk left _clip != null with
_latestClipFrame == null, so RenderClipFrame returned null forever: the box stayed
transparent for the whole alert and the six-animation fallback (which only ran on
setup-time throws) never fired.

Fix: AlertOverlayLayer.Advance gives a started clip a NoFrameFallbackSeconds (1.0s)
grace — produce no frame AND no audio inside it and the clip is torn down, _elapsed
resets, and the SAME alert continues as the animation branch (never blank, never drained
early). Diagnostics stop the lying silence: BeginClip logs box id/path/size + setup
failures; AlertClipDecoder.RunAsync logs frame/chunk end-state and the exception it used
to swallow.

Good Dog test: SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace (transparent
inside grace, disposed past it, animation renders, drains to idle).

Reference: alert playback must degrade to its fallback on dead-air, never vanish —
same contract as every player/booth failure budget (e.g. OBS source fallbacks).
https://obsproject.com/forum/threads/source-visibility-fallback.187383/
2026-09-26 12:08:27 -07:00

260 lines
9.9 KiB
C#
Raw Permalink 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");
}
[Fact]
public void SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace()
{
_app.Run(() =>
{
var box = new Source
{
Type = SourceType.AlertBox,
Name = "Stream Alerts",
Width = 600,
Height = 200,
AlertUseDefaultVideo = true,
};
var scenes = new List<Scene> { new Scene { Name = "Main", Elements = { box } } };
var fake = new FakeAlertClipDecoder();
var layer = new AlertOverlayLayer(
new AlertRenderer(),
clipDecoderFactory: (_, _, _) => fake,
clipPathResolver: _ => @"C:\fake\silent.mp4");
layer.UpdateConfiguredBoxes(scenes);
layer.Enqueue(new ChatMessage { Kind = ChatEventKind.NewMember, AuthorName = "Funder" });
Assert.True(layer.IsPlaying);
Assert.Equal(1, fake.StartCount);
// Still inside the grace window: the decoder produced nothing, the box
// sits transparent — exactly the dead-air state the 2026-09-26 live
// failure showed.
layer.Advance(0.5);
Assert.Null(layer.RenderFrame(box));
Assert.Equal(0, fake.DisposeCount);
// Past the 1s grace: the silent decoder is torn down and the ALERT
// SWITCHES to the six-animation render — never a permanently blank box.
layer.Advance(0.6);
Assert.Equal(1, fake.DisposeCount);
Assert.True(layer.IsPlaying);
var animation = layer.RenderFrame(box);
Assert.NotNull(animation);
Assert.True(HasPixels(animation!), "the fallback must actually draw");
// The animation plays its full duration, then drains to idle.
layer.Advance(3.5);
Assert.True(layer.IsPlaying);
layer.Advance(1.0);
Assert.False(layer.IsPlaying);
Assert.Null(layer.RenderFrame(box));
});
}
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();
}
}