80038ff152
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/
260 lines
9.9 KiB
C#
260 lines
9.9 KiB
C#
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();
|
||
}
|
||
} |