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();
}
}