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;
///
/// 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
/// (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.
///
[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();
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 { 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 { 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 { 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);
});
}
/// A decoder seam the test drives synchronously (no ffmpeg, no threads):
/// the layer calls Start/Stop/Dispose and the test feeds frames/audio/EOF.
private sealed class FakeAlertClipDecoder : IAlertClipDecoder
{
public int StartCount;
public int StopCount;
public int DisposeCount;
public event Action? FrameAvailable;
public event Action? 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();
}
}