Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only. Ref: https://obsproject.com/kb/obs-studio-overview
538 lines
22 KiB
C#
538 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
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.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 47 Good Dog (take four, 2026-09-26): the alert announcement strip must be
|
|
/// DRAWN by the preview pane, in all three display methods, at a pace that is
|
|
/// actually readable.
|
|
/// <para><b>take five, 2026-09-26 (creator ruling):</b> the strip is no longer a global
|
|
/// 1920px bar at the top of the frame — it draws INSIDE the Stream Alerts box, "over the
|
|
/// stream alerts video, not over the entire preview window". The frame is box-sized and
|
|
/// carries the box's origin on <see cref="VideoFrame.Placement"/>; the preview element
|
|
/// and every compositor blit site read that origin instead of a hardcoded 0.</para>
|
|
/// <para>The bug: the strip is a global 1920x48 overlay that existed only as a frame-
|
|
/// pump callback (<c>alertTicker: () => _alertLayer.AlertTickerFrame</c>), so it
|
|
/// reached the stream and never the pane the creator watches — the same class of
|
|
/// defect as the missing IsAlertBox trigger, one layer up: something is produced and
|
|
/// consumed, but nothing ever displays it. The fix is a preview sink on the layer
|
|
/// plus a global <c>AlertTickerElement</c> in PreviewPane.xaml.</para>
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class AlertTickerPreviewDisplayTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public AlertTickerPreviewDisplayTests(RealAppHost app) => _app = app;
|
|
|
|
private const string TickerText = "Funder — Super Chat$10.00";
|
|
private const double ClipSeconds = 10.0;
|
|
|
|
// ---------- the pane actually draws the strip ----------
|
|
|
|
[Fact]
|
|
public void PreviewPane_DrawsTheTickerStrip_AndCollapsesWhenTheAlertEnds()
|
|
{
|
|
_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();
|
|
|
|
var frame = AlertTickerRenderer.Render(
|
|
TickerText, 0.2, AlertDisplayMethod.TickerScroll, ClipSeconds);
|
|
Assert.NotNull(frame);
|
|
|
|
vm.PublishAlertTickerPreview(frame);
|
|
window.UpdateLayout();
|
|
|
|
var pane = (UserControl)window.FindName("PreviewPane")!;
|
|
var holder = Find(pane, e => e is FrameworkElement f
|
|
&& f.Name == "AlertTickerElement") as FrameworkElement;
|
|
Assert.True(holder != null,
|
|
"PreviewPane.xaml has no AlertTickerElement — the strip 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 alert ticker element never bound a bitmap, so the strip is never rendered");
|
|
Assert.IsType<System.Windows.Media.Imaging.WriteableBitmap>(image!.Source);
|
|
|
|
// The alert ends: the strip must go away again, not sit on top of
|
|
// the next scene forever.
|
|
vm.PublishAlertTickerPreview(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 layer actually publishes to that sink ----------
|
|
|
|
[Fact]
|
|
public void AlertLayer_PublishesARealTickerFrameToThePreviewSink()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
foreach (var method in new[]
|
|
{
|
|
AlertDisplayMethod.TickerScroll,
|
|
AlertDisplayMethod.Flash,
|
|
AlertDisplayMethod.Solid,
|
|
})
|
|
{
|
|
var published = new List<VideoFrame?>();
|
|
var layer = new AlertOverlayLayer(new AlertRenderer(),
|
|
tickerPreviewSink: f => published.Add(f));
|
|
try
|
|
{
|
|
var box = new Source
|
|
{
|
|
Name = "Stream Alerts",
|
|
Type = SourceType.AlertBox,
|
|
AlertShowTicker = true,
|
|
AlertDisplayMethod = method,
|
|
// The product's own default for a new alert box
|
|
// (MainViewModel.Sources.cs:54-57) — 680x200 at (620, 430).
|
|
// A box with no geometry defaulted to 1px, which is what made
|
|
// this assertion read 1920 before the strip became box-sized.
|
|
X = 620,
|
|
Y = 430,
|
|
Width = 680,
|
|
Height = 200,
|
|
};
|
|
var scene = new Scene { Name = "Live" };
|
|
scene.Elements.Add(box);
|
|
|
|
// The production entry from the chat feed.
|
|
layer.OnMessageReceived(new ChatMessage
|
|
{
|
|
AuthorName = "Funder",
|
|
Kind = ChatEventKind.SuperChat,
|
|
SuperChatDisplayString = "$10.00",
|
|
}, new[] { scene });
|
|
|
|
var frame = published.LastOrDefault();
|
|
Assert.True(frame != null, $"{method}: the layer published no ticker frame to the preview");
|
|
// CREATOR RULING 2026-09-26: the strip draws INSIDE the Stream
|
|
// Alerts box — "over the stream alerts video, not over the entire
|
|
// preview window". So the frame is the box's width and carries the
|
|
// box's origin; the height stays the natural 48px strip, pinned to
|
|
// the top of the (200px tall) box.
|
|
Assert.Equal(680, frame!.Width);
|
|
Assert.Equal(48, frame.Height);
|
|
Assert.Equal(620, frame.OriginX);
|
|
Assert.Equal(430, frame.OriginY);
|
|
}
|
|
finally
|
|
{
|
|
layer.Dispose();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void AlertLayer_TickerOff_StaysOffThePreviewSink()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
var published = new List<VideoFrame?>();
|
|
var layer = new AlertOverlayLayer(new AlertRenderer(),
|
|
tickerPreviewSink: f => published.Add(f));
|
|
try
|
|
{
|
|
var box = new Source
|
|
{
|
|
Name = "Stream Alerts",
|
|
Type = SourceType.AlertBox,
|
|
AlertShowTicker = false, // creator's existing "Message ticker" switch
|
|
AlertDisplayMethod = AlertDisplayMethod.Solid,
|
|
};
|
|
var scene = new Scene { Name = "Live" };
|
|
scene.Elements.Add(box);
|
|
|
|
layer.OnMessageReceived(new ChatMessage
|
|
{
|
|
AuthorName = "Funder",
|
|
Kind = ChatEventKind.SuperChat,
|
|
SuperChatDisplayString = "$10.00",
|
|
}, new[] { scene });
|
|
|
|
Assert.NotEmpty(published);
|
|
Assert.All(published, f => Assert.Null(f));
|
|
}
|
|
finally
|
|
{
|
|
layer.Dispose();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- the three methods behave as specified ----------
|
|
|
|
[Fact]
|
|
public void TickerScroll_CompletesThreeCrossingsInTheTenSecondAlert()
|
|
{
|
|
// The old constant (140px/s) needed ~17s per pass, so a 10s alert showed the
|
|
// message barely once, entering from the right and never crossing. This is
|
|
// the creator's "readable three times" requirement.
|
|
//
|
|
// The marquee is SEAMLESS — a new copy enters from the right the instant the
|
|
// old one clears the left — so the frame is never blank and "empty frame"
|
|
// can't count a pass. Count the pill's leading edge instead: it sweeps left
|
|
// across one pass, then snaps back to the right edge to start the next.
|
|
var resets = 0;
|
|
var previousEdge = -1;
|
|
for (var t = 0.0; t < ClipSeconds; t += 1.0 / 60.0)
|
|
{
|
|
var frame = AlertTickerRenderer.Render(
|
|
TickerText, t, AlertDisplayMethod.TickerScroll, ClipSeconds);
|
|
Assert.NotNull(frame);
|
|
var edge = LeftmostVisibleColumn(frame!);
|
|
Assert.True(edge >= 0, $"the marquee drew nothing at t={t:0.00}s");
|
|
if (previousEdge >= 0 && edge > previousEdge) resets++;
|
|
previousEdge = edge;
|
|
}
|
|
|
|
// Resets are the pass boundaries. The old fixed 140px/s produced zero of them
|
|
// inside a 10s alert (one pass took ~17s), which is the bug this pins.
|
|
Assert.True(resets >= 3,
|
|
$"expected at least three readable passes in {ClipSeconds}s, counted {resets}");
|
|
}
|
|
|
|
private static int LeftmostVisibleColumn(VideoFrame frame)
|
|
{
|
|
for (var x = 0; x < frame.Width; x++)
|
|
for (var y = 0; y < frame.Height; y++)
|
|
if (frame.BgraPixels[(y * frame.Stride) + (x * 4) + 3] != 0)
|
|
return x;
|
|
return -1;
|
|
}
|
|
|
|
[Fact]
|
|
public void TickerScroll_MidPassFrame_CarriesVisiblePixels()
|
|
{
|
|
// The empty frame at t=0 is the strip still sitting exactly on the right
|
|
// edge; a pass must actually put the announcement on screen.
|
|
var frame = AlertTickerRenderer.Render(
|
|
TickerText, 1.0, AlertDisplayMethod.TickerScroll, ClipSeconds);
|
|
Assert.NotNull(frame);
|
|
Assert.True(frame!.BgraPixels.Any(b => b != 0), "the marquee never drew anything");
|
|
}
|
|
|
|
[Fact]
|
|
public void Solid_IsIdenticalAtEveryMoment()
|
|
{
|
|
var first = AlertTickerRenderer.Render(TickerText, 0.0, AlertDisplayMethod.Solid, ClipSeconds);
|
|
var last = AlertTickerRenderer.Render(TickerText, 9.9, AlertDisplayMethod.Solid, ClipSeconds);
|
|
Assert.NotNull(first);
|
|
Assert.Equal(first!.BgraPixels, last!.BgraPixels);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flash_IsOnForHalfOfEachSecond()
|
|
{
|
|
Assert.NotNull(AlertTickerRenderer.Render(TickerText, 0.2, AlertDisplayMethod.Flash, ClipSeconds));
|
|
Assert.Null(AlertTickerRenderer.Render(TickerText, 0.7, AlertDisplayMethod.Flash, ClipSeconds));
|
|
Assert.NotNull(AlertTickerRenderer.Render(TickerText, 1.2, AlertDisplayMethod.Flash, ClipSeconds));
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_IgnoresEmptyTextAndDefaultsToScroll()
|
|
{
|
|
Assert.Null(AlertTickerRenderer.Render(null, 1.0));
|
|
Assert.Null(AlertTickerRenderer.Render(" ", 1.0));
|
|
}
|
|
|
|
// ---------- the properties panel offers the three methods ----------
|
|
|
|
[Theory]
|
|
[InlineData(AlertDisplayMethod.TickerScroll, "Scroll")]
|
|
[InlineData(AlertDisplayMethod.Flash, "Flash")]
|
|
[InlineData(AlertDisplayMethod.Solid, "Solid")]
|
|
public void PropertiesPanel_ShowsTheChosenDisplayMethod(AlertDisplayMethod method, string label)
|
|
{
|
|
_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();
|
|
var box = new Source
|
|
{
|
|
Name = "Stream Alerts",
|
|
Type = SourceType.AlertBox,
|
|
AlertDisplayMethod = method,
|
|
};
|
|
vm.StagedScene!.Elements.Add(box);
|
|
vm.SelectedElement = box;
|
|
window.UpdateLayout();
|
|
|
|
var panel = (UserControl)window.FindName("LeftPanel")!;
|
|
var combo = FindDisplayComboBox(panel);
|
|
Assert.True(combo != null,
|
|
"the alert properties panel has no display-method selector");
|
|
Assert.Equal(label, ((ComboBoxItem)combo!.SelectedItem!).Content);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void PropertiesPanel_WritesTheChosenDisplayMethodBackToTheModel()
|
|
{
|
|
_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();
|
|
var box = new Source
|
|
{
|
|
Name = "Stream Alerts",
|
|
Type = SourceType.AlertBox,
|
|
AlertDisplayMethod = AlertDisplayMethod.TickerScroll,
|
|
};
|
|
vm.StagedScene!.Elements.Add(box);
|
|
vm.SelectedElement = box;
|
|
window.UpdateLayout();
|
|
|
|
var panel = (UserControl)window.FindName("LeftPanel")!;
|
|
var combo = FindDisplayComboBox(panel)!;
|
|
var solid = combo.Items.OfType<ComboBoxItem>().Single(i => (string)i.Content == "Solid");
|
|
|
|
combo.SelectedItem = solid;
|
|
window.UpdateLayout();
|
|
|
|
// The selector is bound by string ("Solid" -> the enum), so prove the
|
|
// round trip rather than trusting the XAML.
|
|
Assert.Equal(AlertDisplayMethod.Solid, box.AlertDisplayMethod);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effore cleanup */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>The alert display selector is the ComboBox offering exactly
|
|
/// Scroll / Flash / Solid (the chat Font selector offers font names instead).</summary>
|
|
private static ComboBox? FindDisplayComboBox(DependencyObject parent)
|
|
=> Find(parent, e => e is ComboBox c
|
|
&& c.Items.OfType<ComboBoxItem>().Any(i => (string)i.Content == "Scroll")
|
|
&& c.Items.OfType<ComboBoxItem>().Any(i => (string)i.Content == "Solid")) as ComboBox;
|
|
|
|
/// <summary>CREATOR RULING 2026-09-26, the OUTPUT side: "the ticker should appear
|
|
/// over the stream alerts video, not over the entire preview window". The creator is
|
|
/// judging compositing from local recordings, so this renders through the real
|
|
/// compositor and asserts where the pixels actually land: inside the alert box at
|
|
/// (620, 430), and NOT at the top-left corner where the old global strip sat.</summary>
|
|
[Fact]
|
|
public void ComposedOutput_PutsTheTickerInsideTheAlertBox_NotAtTheTopEdge()
|
|
{
|
|
const int boxX = 620, boxY = 430, boxW = 680, boxH = 48;
|
|
|
|
// A solid opaque slab, so "did it composite here?" is a one-pixel question.
|
|
var slab = new byte[boxW * boxH * 4];
|
|
for (var i = 0; i < slab.Length; i += 4)
|
|
{
|
|
slab[i] = 0; slab[i + 1] = 255; slab[i + 2] = 0; slab[i + 3] = 255; // green
|
|
}
|
|
var ticker = new VideoFrame(boxW, boxH, slab) { Placement = (boxX, boxY) };
|
|
|
|
var options = new CompositorOptions
|
|
{
|
|
SourceRectX = 0,
|
|
SourceRectY = 0,
|
|
SourceRectWidth = 1920,
|
|
SourceRectHeight = 1080,
|
|
OutputWidth = 1920,
|
|
OutputHeight = 1080,
|
|
};
|
|
var outFrame = new SceneCompositor().Render(
|
|
new Scene { Name = "Live" },
|
|
_ => null,
|
|
flashFrame: null,
|
|
options,
|
|
tickerFrame: ticker);
|
|
|
|
Assert.True(IsGreen(outFrame, boxX, boxY),
|
|
"the ticker is not in the alert box — the strip is no longer drawn over the "
|
|
+ "Stream Alerts video");
|
|
Assert.True(IsGreen(outFrame, boxX + boxW - 1, boxY + boxH - 1),
|
|
"the ticker's far corner is missing from the alert box");
|
|
Assert.False(IsGreen(outFrame, 0, 0),
|
|
"the ticker is still a full-width bar at the top-left of the frame — it must "
|
|
+ "sit inside the Stream Alerts box, not over the whole canvas");
|
|
}
|
|
|
|
private static bool IsGreen(VideoFrame frame, int x, int y)
|
|
{
|
|
if (x < 0 || y < 0 || x >= frame.Width || y >= frame.Height) return false;
|
|
var i = (y * frame.Width + x) * 4;
|
|
return frame.BgraPixels[i + 1] > 200 && frame.BgraPixels[i + 3] > 200;
|
|
}
|
|
|
|
/// <summary>With the strip living inside the alert box, a scene with NO alert box
|
|
/// has nowhere to put it — so there must be no ticker at all. This is the guard
|
|
/// against the old global strip quietly coming back.</summary>
|
|
[Fact]
|
|
public void ASceneWithNoAlertBox_PublishesNoTicker()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
var layer = new AlertOverlayLayer(new AlertRenderer(), tickerPreviewSink: _ => { });
|
|
var scene = new Scene { Name = "Live" };
|
|
scene.Elements.Add(new Source { Name = "Bg", Type = SourceType.Background });
|
|
|
|
layer.OnMessageReceived(new ChatMessage
|
|
{
|
|
AuthorName = "Funder",
|
|
Kind = ChatEventKind.SuperChat,
|
|
SuperChatDisplayString = "$10.00",
|
|
}, new[] { scene });
|
|
|
|
Assert.Null(layer.AlertTickerFrame);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void SceneTickersKeepIndependentSettings_AndShowQueueWhileAlertBoxShowsActiveEvent()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
const int width = 680;
|
|
const int height = 48;
|
|
var alertBox = new Source
|
|
{
|
|
Name = "Stream Alerts",
|
|
Type = SourceType.AlertBox,
|
|
Width = width,
|
|
Height = height,
|
|
};
|
|
var tickerA = new Source
|
|
{
|
|
Name = "Ticker A",
|
|
Type = SourceType.Ticker,
|
|
Width = width,
|
|
Height = height,
|
|
};
|
|
var tickerB = new Source
|
|
{
|
|
Name = "Ticker B",
|
|
Type = SourceType.Ticker,
|
|
Width = width,
|
|
Height = height,
|
|
};
|
|
tickerA.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
|
|
tickerB.Ticker.DisplayMethod = AlertDisplayMethod.Flash;
|
|
alertBox.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
|
|
var firstScene = new Scene { Name = "Starting", Elements = { alertBox, tickerA } };
|
|
var secondScene = new Scene { Name = "BRB", Elements = { tickerB } };
|
|
using var layer = new AlertOverlayLayer(new AlertRenderer(), tickerPreviewSink: _ => { });
|
|
layer.UpdateConfiguredBoxes(new[] { firstScene, secondScene });
|
|
layer.Enqueue(new ChatMessage
|
|
{
|
|
AuthorName = "Active",
|
|
Kind = ChatEventKind.SuperChat,
|
|
SuperChatDisplayString = "$10.00",
|
|
});
|
|
layer.Enqueue(new ChatMessage
|
|
{
|
|
AuthorName = "Queued",
|
|
Kind = ChatEventKind.NewMember,
|
|
});
|
|
layer.Advance(0.7);
|
|
|
|
var activeAlert = layer.AlertBoxTickerFrame(alertBox);
|
|
var recentFeed = layer.RenderSceneTicker(tickerA);
|
|
|
|
Assert.NotSame(tickerA.Ticker, tickerB.Ticker);
|
|
Assert.Equal(AlertDisplayMethod.Solid, tickerA.Ticker.DisplayMethod);
|
|
Assert.Equal(AlertDisplayMethod.Flash, tickerB.Ticker.DisplayMethod);
|
|
Assert.NotNull(activeAlert);
|
|
Assert.NotNull(recentFeed);
|
|
Assert.Null(layer.RenderSceneTicker(tickerB));
|
|
Assert.False(activeAlert!.BgraPixels.SequenceEqual(recentFeed!.BgraPixels),
|
|
"the standalone feed must include the queued event while Stream Alerts shows only the active event");
|
|
});
|
|
}
|
|
|
|
/// <summary>A short alert box must clip the strip's rows, not overrun the target
|
|
/// buffer. The strip rasterises at its natural 48px, so a 20px-tall box used to be
|
|
/// a heap-corrupting write once the strip was composited at the box's size.</summary>
|
|
[Fact]
|
|
public void ABoxShorterThanTheStrip_ClipsRowsInsteadOfOverrunning()
|
|
{
|
|
var box = new Source
|
|
{
|
|
Name = "Stream Alerts",
|
|
Type = SourceType.AlertBox,
|
|
AlertShowTicker = true,
|
|
X = 620,
|
|
Y = 430,
|
|
Width = 680,
|
|
Height = 20,
|
|
};
|
|
var frame = AlertTickerRenderer.Render(
|
|
TickerText, 0.2, AlertDisplayMethod.Solid, ClipSeconds,
|
|
readsPerAlert: 3, width: 680, height: 20);
|
|
|
|
Assert.NotNull(frame);
|
|
Assert.Equal(680, frame!.Width);
|
|
Assert.Equal(20 * 680 * 4, frame.BgraPixels.Length);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|