Files
LlamaCasty/ytLive.Tests/BrandFlashOutputTests.cs
gramps 9761b1d4be branding credit: run in every scene and in recordings, not only while live
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.

It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.

Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.

Tests (12 facts in BrandFlashOutputTests, +2):
  Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
    never-live VM past the 5s first-flash delay and asks the frame the pump would
    composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
    advances the cadence while the presenter is running, a credit coming out
    proves Start() happened at construction.
  ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
    edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
    30-60s interval, which a synchronous test body cannot observe.

Full suite 364/364.
2026-09-27 09:33:30 -07:00

577 lines
24 KiB
C#

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;
/// <summary>
/// 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.
/// <para>The bug: the credit existed only as a WPF <c>BrandFlashLayer</c> 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 <c>flashFrame</c> slot
/// for exactly this and it was never wired.</para>
/// <para>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.</para>
/// </summary>
[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<System.Windows.Media.Imaging.WriteableBitmap>(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<Point>();
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<double>();
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 ----------
/// <summary>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 <c>UpdateLiveVisuals</c>'s IsLive branch, so a
/// recording made WITHOUT ever going live carried no credit at all.
/// <para>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 <c>StartAsync</c> with the same <c>brandFlash:</c> delegate)
/// the recording carries the credit too.</para></summary>
[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 */ }
}
});
}
/// <summary>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 <c>Start()</c> again: a key entered mid-session used to leave the credit
/// dead until the process restarted. Re-enabling must resume the cadence.</summary>
[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<DependencyObject, bool> 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;
}
/// <summary>Drive the clock in 1/30s steps, like the real timer does.</summary>
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);
}
/// <summary>Same stepping, against the ViewModel's own presenter.</summary>
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);
}
/// <summary>Alpha bounding box of everything the frame actually drew.</summary>
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);
}
/// <summary>True when the frame carries near-white opaque pixels — the credit's core.
/// A black background cannot produce them, so this cannot false-positive.</summary>
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;
}
/// <summary>An opaque black master frame. The credit's core is near-white, so it
/// cannot false-positive against this and the assertion stays honest.</summary>
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 };
}
/// <summary>Captures the frames the pump actually submits — the same trick the
/// production encoder uses, so the assertion is about the bytes on the wire.</summary>
private sealed class CapturingEncoder : IFfmpegEncoder
{
public readonly List<VideoFrame> Frames = new();
public readonly TaskCompletionSource FirstFrame =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public int DroppedFrames { get; set; }
public event EventHandler<StreamHealth>? HealthUpdated;
public event EventHandler<string>? 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);
}
}