7f14ffb11e
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).
357 lines
14 KiB
C#
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;
|
|
}
|
|
}
|