Files
LlamaCasty/ytLive.Tests/AlertTickerPreviewDisplayTests.cs
T
gramps 7f14ffb11e TASK 47 take four: alert ticker visible in the preview + 3 display methods
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).

- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
  is always the UI thread; MainViewModel.AlertTicker writes it into one reused
  WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
  Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
  showed the text once, entering from the right and never crossing. Paced in reads per
  alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
  from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
  alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
  Duration: choose how long your alert stays on your stream" and "Text Delay", never a
  scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
  Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
  table_info migration, appended LAST in the SELECT because the Source reader is
  positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.

Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.

Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
2026-09-26 15:10:26 -07:00

357 lines
14 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>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 is global, 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 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,
};
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");
Assert.Equal(1920, frame!.Width);
Assert.Equal(48, frame.Height);
}
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;
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;
}
}