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; /// /// 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. /// take five, 2026-09-26 (creator ruling): 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 ; the preview element /// and every compositor blit site read that origin instead of a hardcoded 0. /// The bug: the strip is a global 1920x48 overlay that existed only as a frame- /// pump callback (alertTicker: () => _alertLayer.AlertTickerFrame), 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 AlertTickerElement in PreviewPane.xaml. /// [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(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(); 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(); 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().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 */ } } }); } /// The alert display selector is the ComboBox offering exactly /// Scroll / Flash / Solid (the chat Font selector offers font names instead). private static ComboBox? FindDisplayComboBox(DependencyObject parent) => Find(parent, e => e is ComboBox c && c.Items.OfType().Any(i => (string)i.Content == "Scroll") && c.Items.OfType().Any(i => (string)i.Content == "Solid")) as ComboBox; /// 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. [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; } /// 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. [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); }); } /// 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. [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 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; } }