using System; using System.Collections.Generic; using System.Linq; 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"); } private static byte AlphaAt(VideoFrame frame) => frame.BgraPixels[3]; private static bool HasPixels(VideoFrame frame) => frame.BgraPixels.Any(b => b != 0); /// 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(); } }