using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Models; using ytLive.Services; using ytLive.Services.Compositor; using ytLive.Services.Encoder; using ytLive.ViewModels; namespace ytLive.Tests; /// /// Good Dog (2026-09-26): the free-tier branding credit must be COMPOSITED INTO THE /// BROADCAST, and must be exactly what the creator sees in the preview. /// The bug: the credit existed only as a WPF BrandFlashLayer in /// PreviewPane.xaml — a 0.25-opacity TextBlock on a 300s timer. A viewer of the /// stream or the recording never saw it, so the "free tier shows branding" promise was /// simply not being kept. The frame pump has carried an unused flashFrame slot /// for exactly this and it was never wired. /// The fix routes ONE rendered frame to both consumers: the output compositor and /// the preview pane. These facts pin the behaviour the creator specified — in the /// recording, 2 seconds, random spot every time, inside the frame, and never for a /// paying customer. /// [Collection("RealApp")] public sealed class BrandFlashOutputTests { private readonly RealAppHost _app; public BrandFlashOutputTests(RealAppHost app) => _app = app; // ---------- the headline: the credit is in the encoded output ---------- [Fact] public async Task FreeTier_Credit_ReachesTheEncodedOutputFrame() { await _app.RunAsync(async () => { // A real 1920x1080 pump: the credit is authored at master size, so a // toy-sized canvas would clip it away and prove nothing. var scene = new Scene { Name = "Live" }; scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0", }); using var presenter = new BrandFlashPresenter(new Random(1)); presenter.Show(); presenter.Advance(0.5); // land at the top of the full-opacity hold var credit = presenter.Frame; Assert.NotNull(credit); var encoder = new CapturingEncoder(); using var pump = new FramePump( sceneProvider: () => scene, frameResolver: _ => null, compositorOptions: () => new CompositorOptions { SourceRectX = 0, SourceRectY = 0, SourceRectWidth = BrandFlashPresenter.MasterWidth, SourceRectHeight = BrandFlashPresenter.MasterHeight, OutputWidth = BrandFlashPresenter.MasterWidth, OutputHeight = BrandFlashPresenter.MasterHeight, }, encoderOptions: () => new EncoderOptions { RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc", Width = BrandFlashPresenter.MasterWidth, Height = BrandFlashPresenter.MasterHeight, Fps = 30, }, encoderFactory: () => encoder, pacingDelay: (_, _) => Task.CompletedTask, brandFlash: () => presenter.Frame); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); await pump.StartAsync(cts.Token); await encoder.FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(15)); await pump.StopAsync(); var sent = encoder.Frames[0]; Assert.True(HasBrightPixels(sent), "the branding credit never reached a frame submitted to the encoder — " + "it is still preview-only, so nobody watching the stream sees it"); // The preview must show the very same thing, not a separate XAML text block. Assert.Equal(BrandFlashPresenter.MasterWidth, sent.Width); Assert.Equal(BrandFlashPresenter.MasterHeight, sent.Height); }); } // ---------- a fully static scene must not swallow it ---------- [Fact] public async Task FullyStaticScene_StillShowsTheCredit() { await _app.RunAsync(async () => { // Regression on a PRE-EXISTING defect found while wiring this up: the // fully-static shortcut stretched the cached bake and returned it, dropping // every per-frame overlay on the floor — the credit, and (before this // change) the social bar and the alert ticker too. A creator with a single // static background would have seen the flash preview-only, forever. var black = SolidBlackFrame(); var scene = new Scene { Name = "Live" }; // One Background element and nothing dynamic => GetSplitPoint == // Elements.Count, which is the branch that used to drop the overlays. scene.Elements.Add(new Source { Type = SourceType.Background }); using var presenter = new BrandFlashPresenter(new Random(2)); presenter.Show(); presenter.Advance(0.5); Assert.NotNull(presenter.Frame); var encoder = new CapturingEncoder(); using var pump = new FramePump( sceneProvider: () => scene, frameResolver: e => e is Source { Type: SourceType.Background } ? black : null, compositorOptions: () => new CompositorOptions { SourceRectX = 0, SourceRectY = 0, SourceRectWidth = BrandFlashPresenter.MasterWidth, SourceRectHeight = BrandFlashPresenter.MasterHeight, OutputWidth = BrandFlashPresenter.MasterWidth, OutputHeight = BrandFlashPresenter.MasterHeight, }, encoderOptions: () => new EncoderOptions { RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc", Width = BrandFlashPresenter.MasterWidth, Height = BrandFlashPresenter.MasterHeight, Fps = 30, }, encoderFactory: () => encoder, pacingDelay: (_, _) => Task.CompletedTask, brandFlash: () => presenter.Frame, sceneGraph: new SceneGraph()); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); await pump.StartAsync(cts.Token); await encoder.FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(15)); await pump.StopAsync(); Assert.True(HasBrightPixels(encoder.Frames[0]), "a fully static scene dropped the branding credit — the fully-static path " + "stretches the cached bake and used to discard every per-frame overlay"); }); } // ---------- the preview shows the same rendered credit ---------- [Fact] public void PreviewPane_ShowsTheSameCreditThatGoesToTheStream() { _app.Run(() => { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; var window = new MainWindow(); var vm = (MainViewModel)window.DataContext; try { window.Show(); window.UpdateLayout(); using var presenter = new BrandFlashPresenter(new Random(3)); presenter.Show(); presenter.Advance(0.5); var credit = presenter.Frame; Assert.NotNull(credit); vm.PublishBrandFlashPreview(credit); window.UpdateLayout(); var pane = (UserControl)window.FindName("PreviewPane")!; var holder = Find(pane, e => e is FrameworkElement f && f.Name == "BrandFlashElement") as FrameworkElement; Assert.True(holder != null, "PreviewPane.xaml has no BrandFlashElement — the credit is a global " + "overlay, so it needs its own root, not a Source's Image"); Assert.Equal(Visibility.Visible, holder!.Visibility); var image = Find(holder, e => e is Image) as Image; Assert.True(image != null && image.Source != null, "the branding element never bound a bitmap, so the credit is never drawn"); Assert.IsType(image!.Source); // It must go away again rather than sit on the next scene forever. vm.PublishBrandFlashPreview(null); window.UpdateLayout(); Assert.Equal(Visibility.Collapsed, holder.Visibility); } finally { window.Close(); MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } }); } // ---------- the specified numbers ---------- [Fact] public void Credit_LastsTwoSeconds_ThenIsGone() { // 500ms fade in, 1000ms held, 500ms fade out. Assert.Equal(0.0, BrandFlashPresenter.OpacityAt(0.0), 3); Assert.Equal(0.5, BrandFlashPresenter.OpacityAt(0.25), 3); Assert.Equal(1.0, BrandFlashPresenter.OpacityAt(0.5), 3); Assert.Equal(1.0, BrandFlashPresenter.OpacityAt(1.49), 3); Assert.Equal(0.5, BrandFlashPresenter.OpacityAt(1.75), 3); Assert.Equal(0.0, BrandFlashPresenter.OpacityAt(2.0), 3); Assert.Equal(2.0, BrandFlashPresenter.PresentationSeconds, 3); } [Fact] public void Credit_IsGoneExactlyWhenTheTwoSecondsAreUp() { _app.Run(() => { using var presenter = new BrandFlashPresenter(new Random(4)); presenter.Show(); // At exactly t=0 the fade-in has not started, so opacity is 0 and there is // deliberately nothing to blit — the credit appears on the next tick. Assert.Null(presenter.Frame); presenter.Advance(1.8); Assert.NotNull(presenter.Frame); // 1.8s: inside the fade-out Assert.True(presenter.IsPresenting); presenter.Advance(0.3); Assert.Null(presenter.Frame); // 2.1s in — over Assert.False(presenter.IsPresenting); }); } [Fact] public void EveryPresentation_PicksADifferentSpot_AndAlwaysLandsOnTheCanvas() { _app.Run(() => { using var presenter = new BrandFlashPresenter(new Random(7)); var spots = new List(); for (var run = 0; run < 40; run++) { presenter.Show(); presenter.Advance(0.5); var frame = presenter.Frame; Assert.NotNull(frame); // The credit must be FULLY on the canvas — this is the "how far can the // left margin travel" limit: 0 .. (canvas - creditWidth), never wrapped. var (minX, minY, maxX, maxY) = AlphaBounds(frame!); Assert.True(minX >= 0 && minY >= 0, $"the credit hangs off the top-left at ({minX},{minY})"); Assert.True(maxX < frame.Width && maxY < frame.Height, $"the credit runs off the bottom-right at ({maxX},{maxY})"); spots.Add(new Point(minX, minY)); } Assert.True(spots.Distinct().Count() > spots.Count / 2, "the spot barely changed across 40 presentations — it is not random per flash"); }); } [Fact] public void Placement_StaysInsideTheFrameForAnyTextSize() { var rand = new Random(11); for (var i = 0; i < 500; i++) { var w = rand.Next(1, 4000); var h = rand.Next(1, 3000); var p = BrandFlashPresenter.ComputePlacement(w, h, BrandFlashPresenter.MasterWidth, BrandFlashPresenter.MasterHeight, rand); Assert.InRange(p.X, 0, Math.Max(0, BrandFlashPresenter.MasterWidth - w)); Assert.InRange(p.Y, 0, Math.Max(0, BrandFlashPresenter.MasterHeight - h)); } // A credit wider than the canvas still gets a legal offset rather than throwing. var over = BrandFlashPresenter.ComputePlacement(5000, 5000, BrandFlashPresenter.MasterWidth, BrandFlashPresenter.MasterHeight, rand); Assert.Equal(0, over.X); Assert.Equal(0, over.Y); } [Fact] public void Cadence_RepeatsEveryThirtyToSixtySeconds() { _app.Run(() => { using var presenter = new BrandFlashPresenter(new Random(5)); presenter.Start(); try { // First credit 5s after go-live, then not again inside 30s. Step(presenter, 4.0); Assert.Null(presenter.Frame); Step(presenter, 1.5); Assert.NotNull(presenter.Frame); // Sample the start-to-start intervals the presenter actually schedules // rather than trusting the constants. A rising edge is a new credit. // The first gap is the deliberate 5s opener, so it is excluded. var starts = new List(); var t = 0.0; var wasPresenting = presenter.IsPresenting; while (t < 1200.0) { presenter.Advance(0.05); t += 0.05; var presenting = presenter.IsPresenting; if (!wasPresenting && presenting) starts.Add(t); wasPresenting = presenting; } var intervals = starts.Zip(starts.Skip(1), (a, b) => b - a) .Where(g => g > 6.0) // drops the single 5s opener .ToList(); Assert.True(intervals.Count >= 15, $"expected ~20 credits in 1200s of a 30-60s cadence, saw {starts.Count}"); // The whole point: no beat a viewer could tune out, and none of the // collapsing gaps an absolute-deadline bug produces. Assert.All(intervals, g => Assert.InRange(g, 30.0 - 0.2, 60.0 + 0.2)); Assert.True(intervals.Distinct().Count() > intervals.Count / 2, "the cadence barely varied — it is not random per flash"); } finally { presenter.Stop(); } }); } [Fact] public void Credit_IsNeverEmittedForAPayingCustomer() { _app.Run(() => { using var presenter = new BrandFlashPresenter(new Random(6)); presenter.Show(); presenter.Advance(0.5); Assert.NotNull(presenter.Frame); // A key entered mid-credit must kill it on the next tick, not let the // advertisement finish playing. presenter.Enabled = false; Assert.Null(presenter.Frame); Assert.False(presenter.IsPresenting); presenter.Advance(0.1); presenter.Show(); Assert.Null(presenter.Frame); // Going back to free tier restores it. presenter.Enabled = true; presenter.Show(); presenter.Advance(0.5); Assert.NotNull(presenter.Frame); }); } [Fact] public void BrandFlashEnabled_IsDerivedFromTheLicence_AndCannotBeSwitchedOn() { var prop = typeof(MainViewModel).GetProperty(nameof(MainViewModel.BrandFlashEnabled)); Assert.NotNull(prop); Assert.Null(prop!.SetMethod); Assert.True(prop.CanRead, "the preview needs to read the gate state"); } // ---------- the credit is NOT a go-live-only behaviour ---------- /// Reported 2026-09-26: "the made with llamacasty flash should appear in /// all scenes, not just live" and "should also appear in recordings". The presenter /// used to be Start/Stop-ed from UpdateLiveVisuals's IsLive branch, so a /// recording made WITHOUT ever going live carried no credit at all. /// This drives a real, freshly-constructed ViewModel that has never gone /// live, advances past the 5s first-flash delay and asks the frame the frame pump /// would composite. A non-null frame here is the whole fix: the cadence is running /// for the life of the app, and since go-live and local recording are the SAME /// FramePump (both call StartAsync with the same brandFlash: delegate) /// the recording carries the credit too. [Fact] public void Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt() { _app.Run(() => { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; var window = new MainWindow(); var vm = (MainViewModel)window.DataContext; try { window.Show(); window.UpdateLayout(); // The precondition that used to guarantee an empty credit: offline, // not recording, never broadcast. Assert.False(vm.IsLive, "this test is about the NOT-live case"); Assert.False(vm.IsRecording); Assert.Equal(StreamStatus.Offline, vm.StreamStatus); // No credit is due yet — the first one lands 5s in. Assert.Null(vm.BrandFlashFrame()); // Step it in 1/30s increments like the real DispatcherTimer. Do NOT // pass 5.1 in one call: Advance both opens the presentation AND ages // it by the same delta, so a single 5.1s step would blow clean through // the 2s window and the credit would be gone before it was ever read. Step(vm, 5.1); var frame = vm.BrandFlashFrame(); Assert.True(frame != null, "no credit with no live session: the cadence is still gated on " + "IsLive, so recordings made without going live carry no branding"); Assert.Equal(1920, frame!.Width); Assert.Equal(1080, frame.Height); // And it reaches the preview pane in this state too, not just the encoder. vm.PublishBrandFlashPreview(frame); window.UpdateLayout(); var pane = (UserControl)window.FindName("PreviewPane")!; var holder = Find(pane, e => e is FrameworkElement f && f.Name == "BrandFlashElement") as FrameworkElement; Assert.Equal(Visibility.Visible, holder!.Visibility); } finally { window.Close(); MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } }); } /// The presenter is started ONCE for the life of the app now, so the /// premium edge stopping its timer is no longer rescued by the next go-live /// calling Start() again: a key entered mid-session used to leave the credit /// dead until the process restarted. Re-enabling must resume the cadence. [Fact] public void ADowngradeMidSession_RestartsTheCadenceTimer() { _app.Run(() => { using var presenter = new BrandFlashPresenter(new Random(11)); presenter.Start(); Assert.True(presenter.IsCadenceTimerEnabled, "Start() must leave the cadence timer running"); presenter.Enabled = false; // premium -> the advertisement is cut and the timer stops Assert.False(presenter.IsCadenceTimerEnabled); presenter.Enabled = true; // key entered mid-session: no second Start() will come Assert.True(presenter.IsCadenceTimerEnabled, "the credit stayed dead after a downgrade — the premium edge stopped the " + "timer and nothing restarted it, because Start() is now a one-time " + "app-startup call rather than a per-go-live one"); }); } // ---------- helpers ---------- private static DependencyObject? Find(DependencyObject parent, Func predicate) { for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) { var child = VisualTreeHelper.GetChild(parent, i); if (predicate(child)) return child; var found = Find(child, predicate); if (found != null) return found; } return null; } /// Drive the clock in 1/30s steps, like the real timer does. private static void Step(BrandFlashPresenter presenter, double seconds) { for (var t = 0.0; t < seconds; t += 1.0 / 30.0) presenter.Advance(1.0 / 30.0); } /// Same stepping, against the ViewModel's own presenter. private static void Step(MainViewModel vm, double seconds) { for (var t = 0.0; t < seconds; t += 1.0 / 30.0) vm.AdvanceBrandFlash(1.0 / 30.0); } /// Alpha bounding box of everything the frame actually drew. private static (int MinX, int MinY, int MaxX, int MaxY) AlphaBounds(VideoFrame frame) { var minX = int.MaxValue; var minY = int.MaxValue; var maxX = -1; var maxY = -1; for (var y = 0; y < frame.Height; y++) for (var x = 0; x < frame.Width; x++) { if (frame.BgraPixels[(y * frame.Stride) + (x * 4) + 3] == 0) continue; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } Assert.True(maxX >= 0, "the credit frame drew no visible pixels at all"); return (minX, minY, maxX, maxY); } /// True when the frame carries near-white opaque pixels — the credit's core. /// A black background cannot produce them, so this cannot false-positive. private static bool HasBrightPixels(VideoFrame frame) { for (var i = 0; i < frame.BgraPixels.Length; i += 4) { if (frame.BgraPixels[i + 3] < 200) continue; if (frame.BgraPixels[i] > 200 && frame.BgraPixels[i + 1] > 200 && frame.BgraPixels[i + 2] > 200) return true; } return false; } /// An opaque black master frame. The credit's core is near-white, so it /// cannot false-positive against this and the assertion stays honest. private static VideoFrame SolidBlackFrame() { var w = BrandFlashPresenter.MasterWidth; var h = BrandFlashPresenter.MasterHeight; var pixels = new byte[w * h * 4]; for (var i = 0; i < pixels.Length; i += 4) pixels[i + 3] = 255; return new VideoFrame(w, h, pixels) { IsOpaque = true }; } /// Captures the frames the pump actually submits — the same trick the /// production encoder uses, so the assertion is about the bytes on the wire. private sealed class CapturingEncoder : IFfmpegEncoder { public readonly List Frames = new(); public readonly TaskCompletionSource FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously); public int DroppedFrames { get; set; } public event EventHandler? HealthUpdated; public event EventHandler? ProcessFailed; public Task StartAsync(EncoderOptions options, CancellationToken ct = default) => Task.CompletedTask; public Task SubmitFrameAsync(VideoFrame frame, CancellationToken ct = default) { Frames.Add(new VideoFrame(frame.Width, frame.Height, (byte[])frame.BgraPixels.Clone())); FirstFrame.TrySetResult(); return Task.CompletedTask; } public Task StopAsync(CancellationToken ct = default) => Task.CompletedTask; public void Dispose() { } // Never invoked here; present so the fake satisfies the interface the same way // FramePumpTests' fake does. public void RaiseFailed(string message) => ProcessFailed?.Invoke(this, message); public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health); } }