Files
LlamaCasty/ytLive.Tests/AlertTickerPreviewDisplayTests.cs
gramps 938c5b3de4 alert ticker: draw it inside the Stream Alerts box, not as a top-edge bar
Creator 2026-09-26: "the ticker should appear over the stream alerts video, not over
the entire preview window". It was a GLOBAL 1920x48 bar blitted last at a hardcoded
(0, 0) -- it covered the whole preview width, not the alert video.

The strip is now rendered at the alert box's own size and the box's origin travels on
the frame in a new VideoFrame.Placement ((int X, int Y)?). OriginX/OriginY default to
0 when Placement is null, so every full-canvas overlay (the branding flash) is
unaffected. Every ticker blit site now reads tickerFrame.OriginX/OriginY instead of a
literal 0, 0 -- SceneCompositor x2 and FramePump's static-bake Overlay path -- and
PreviewPane.xaml's AlertTickerElement binds the same rect (AlertTickerLeft/Top/
Width/Height), so the preview cannot drift from the recording the creator is judging.

Why the position rides on the frame rather than in a full-canvas frame: a 1920x1080
overlay is 8.3MB of large-object-heap garbage per tick, ~2.5GB churned over one 10s
alert at 30fps. A box-sized strip is ~370KB. The branding flash does render
full-canvas but RECYCLES one 8MB buffer and bumps Epoch, so it never allocates per
frame; the ticker allocates per call, so it has to stay small.

Also fixed: CopyStrip now clips ROWS to the target height. The pill rasterises at its
natural 48px, so an alert box shorter than that would have written past the end of the
target buffer once the strip became box-sized.

No alert box in the scene now means no ticker at all -- there is no global position
left for it, and this is the guard against the old bar quietly coming back.

Tests (3 new facts, 27 in the file):
  ComposedOutput_PutsTheTickerInsideTheAlertBox_NotAtTheTopEdge -- renders through
    the real SceneCompositor and asserts the pixels land at (620, 430) and NOT at the
    top-left corner. The creator is judging compositing from local recordings, so the
    OUTPUT side is the side that needed proving.
  ASceneWithNoAlertBox_PublishesNoTicker
  ABoxShorterThanTheStrip_ClipsRowsInsteadOfOverrunning
Updated AlertLayer_PublishesARealTickerFrameToThePreviewSink, which asserted the old
1920 width from a box with no geometry (defaulted to 1px); it now uses the product's
own default box (680x200 at 620,430, MainViewModel.Sources.cs:54-57) and pins the
frame size and origin.

Full suite 367/367.
2026-09-27 09:43:26 -07:00

476 lines
19 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);
});
}
/// <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;
}
}