d4aa588da0
Replace the external StreamElements feed with NATIVE YouTube events per the creator's 2026-09-23 question. Chat parity half: - YouTubeChatService decodes ALL SIX liveChat/messages event types into ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/ giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay rows are real now — and re-arms its one-shot poll on the server's pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms, maxResults=2000). ParsePage internal static seam + ChatPage record for deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage (TASK 41) preserved. Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no menus/polls, sub mention = chat row only, NO viewer count): - New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate mirroring chat; idle = transparent). - AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/ count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/ chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card drawing the 'made with LlamaCasty!' brand line. - AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview, Advance(double) as the deterministic test clock; ChatEventKind.None rows never enqueue. - Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews, Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged (member->NewMember, superchat->SuperChat). Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) — ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing; six events play pairwise-distinct moving frames then drain to null. Gate: clean build 0 warnings; full suite 316/317, the one failure (LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the known environmental class (real-mouse-drag no-ops with a game/fullscreen window focused). References (OBS/overlay ecosystem): - streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList - OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
184 lines
7.9 KiB
C#
184 lines
7.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 42 Good Dog: the native alerts unit (StreamElements removed — events are
|
|
/// now real YouTube chat events with native celebration animations). One [Fact],
|
|
/// two halves pinned together on the RealApp STA host so the WPF raster is real:
|
|
/// (1) ParsePage classifies all SIX chat event types + a plain text row (the former
|
|
/// "event rows render empty" gap) AND honors the streamList cadence fields; (2) the
|
|
/// AlertOverlayLayer plays them as PART OF ONE QUEUE of six UNIQUE animations
|
|
/// (pairwise-distinct hold frames), never plays a subscriber-mention (None rows
|
|
/// don't enqueue — a mention is a chat row, not an alert, per the creator ruling),
|
|
/// and drains back to the transparent/idle state.
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class AlertLayerTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public AlertLayerTests(RealAppHost app) => _app = app;
|
|
|
|
private const string Payload = /*lang=json,strict*/ """
|
|
{
|
|
"nextPageToken": "Cg0KC0NOTIFTEST",
|
|
"pollingIntervalMillis": 1753,
|
|
"items": [
|
|
{ "id": "t1",
|
|
"snippet": { "type": "textMessageEvent", "textMessageDetails": { "messageText": "plain row" } },
|
|
"authorDetails": { "displayName": "Chatter" } },
|
|
{ "id": "sc1",
|
|
"snippet": { "type": "superChatEvent", "superChatDetails": { "displayString": "$10.00", "currency": "USD", "amountMicros": 10000000, "userComment": "keep the llama going" } },
|
|
"authorDetails": { "displayName": "Funder" } },
|
|
{ "id": "ss1",
|
|
"snippet": { "type": "superStickerEvent", "superStickerDetails": { "stickerDescription": "Good Vibes!!!", "displayString": "$2.00", "currency": "USD", "userComment": "" } },
|
|
"authorDetails": { "displayName": "Sticker Stan" } },
|
|
{ "id": "nm1",
|
|
"snippet": { "type": "newSponsorEvent", "membershipDetails": { "memberLevelName": "New member" } },
|
|
"authorDetails": { "displayName": "Joiner", "isChatSponsor": true, "badgeText": "New member" } },
|
|
{ "id": "mg1",
|
|
"snippet": { "type": "membershipGiftingEvent", "membershipGiftingDetails": { "giftNewMembersCount": 3 } },
|
|
"authorDetails": { "displayName": "Gifter" } },
|
|
{ "id": "gr1",
|
|
"snippet": { "type": "giftMembershipReceivedEvent", "giftMembershipReceivedDetails": { "gifterName": "Gifter" } },
|
|
"authorDetails": { "displayName": "Recipient" } },
|
|
{ "id": "mm1",
|
|
"snippet": { "type": "memberMilestoneChatEvent", "textMessageDetails": { "messageText": "I've been a member for 12 months!" } },
|
|
"authorDetails": { "displayName": "Veteran", "isChatSponsor": true, "badgeText": "Member" } }
|
|
]
|
|
}
|
|
""";
|
|
|
|
[Fact]
|
|
public void Alerts_ParseSixEventKinds_PlaySixUniqueAnimations_DrainIdle()
|
|
{
|
|
_app.Run(Run);
|
|
}
|
|
|
|
private void Run()
|
|
{
|
|
// ─── 1. Parse: all six event kinds classified, cadence honored ───
|
|
var page = YouTubeChatService.ParsePage(Payload);
|
|
Assert.Equal("Cg0KC0NOTIFTEST", page.NextPageToken);
|
|
Assert.Equal(1753, page.PollIntervalMs);
|
|
Assert.Equal(7, page.Messages.Count);
|
|
|
|
var byKind = page.Messages.ToDictionary(m => m.Kind);
|
|
Assert.Equal(ChatEventKind.None, byKind[ChatEventKind.None].Kind); // the plain chat row
|
|
Assert.Equal(ChatEventKind.SuperChat, byKind[ChatEventKind.SuperChat].Kind);
|
|
Assert.Equal(ChatEventKind.SuperSticker, byKind[ChatEventKind.SuperSticker].Kind);
|
|
Assert.Equal(ChatEventKind.NewMember, byKind[ChatEventKind.NewMember].Kind);
|
|
Assert.Equal(ChatEventKind.MemberGift, byKind[ChatEventKind.MemberGift].Kind);
|
|
Assert.Equal(ChatEventKind.GiftReceived, byKind[ChatEventKind.GiftReceived].Kind);
|
|
Assert.Equal(ChatEventKind.MemberMilestone, byKind[ChatEventKind.MemberMilestone].Kind);
|
|
|
|
var superChat = byKind[ChatEventKind.SuperChat];
|
|
Assert.Equal("$10.00", superChat.SuperChatDisplayString);
|
|
Assert.Equal(10.0, superChat.SuperChatAmount, 3);
|
|
Assert.Equal("keep the llama going", superChat.Message);
|
|
|
|
var sticker = byKind[ChatEventKind.SuperSticker];
|
|
Assert.Equal("Good Vibes!!!", sticker.SuperStickerDescription);
|
|
|
|
var member = byKind[ChatEventKind.NewMember];
|
|
Assert.True(member.IsMember);
|
|
|
|
var gift = byKind[ChatEventKind.MemberGift];
|
|
Assert.Equal(3, gift.GiftNewMembersCount);
|
|
|
|
var received = byKind[ChatEventKind.GiftReceived];
|
|
Assert.Equal("Gifter", received.GifterName);
|
|
|
|
var milestone = byKind[ChatEventKind.MemberMilestone];
|
|
Assert.Equal(12, milestone.MilestoneMonths);
|
|
|
|
// Rows that were EMPTY before this work now carry a real message.
|
|
Assert.Contains("3 memberships", gift.Message);
|
|
Assert.Contains("gifted you a membership", received.Message);
|
|
|
|
// ─── 2. Playback: queue, uniqueness, drain ───
|
|
var layer = new AlertOverlayLayer(new AlertRenderer());
|
|
var box = new Source { Type = SourceType.AlertBox, Name = "Stream Alerts", Width = 600, Height = 200 };
|
|
|
|
// A subscriber mention (None) is a chat row — no alert plays.
|
|
layer.Enqueue(byKind[ChatEventKind.None]);
|
|
Assert.False(layer.IsPlaying);
|
|
Assert.Null(layer.RenderFrame(box));
|
|
Assert.Null(layer.RenderFrame(box));
|
|
|
|
var eventTypes = new[]
|
|
{
|
|
ChatEventKind.SuperChat, ChatEventKind.SuperSticker, ChatEventKind.NewMember,
|
|
ChatEventKind.MemberGift, ChatEventKind.GiftReceived, ChatEventKind.MemberMilestone,
|
|
};
|
|
var holdFrames = new Dictionary<ChatEventKind, VideoFrame>();
|
|
|
|
foreach (var kind in eventTypes)
|
|
{
|
|
layer.Enqueue(byKind[kind]);
|
|
Assert.True(layer.IsPlaying, $"{kind} should be playing after enqueue");
|
|
|
|
var samples = new List<VideoFrame>();
|
|
for (var step = 0; step < 26; step++) // 12.5s covers the longest 5.0s animation
|
|
{
|
|
var frame = layer.RenderFrame(box);
|
|
if (frame != null)
|
|
{
|
|
Assert.Equal(600, frame.Width);
|
|
Assert.Equal(200, frame.Height);
|
|
samples.Add(frame);
|
|
}
|
|
layer.Advance(0.5);
|
|
}
|
|
|
|
Assert.True(samples.Count >= 3, $"{kind}: expected several non-null frames, got {samples.Count}");
|
|
Assert.True(samples.Any(HasPixels), $"{kind}: a hold frame must not be blank");
|
|
Assert.True(AnyPairDiffer(samples), $"{kind}: the animation must move, not hold one static frame");
|
|
Assert.False(layer.IsPlaying, $"{kind}: playback must drain once the animation completes");
|
|
Assert.Null(layer.RenderFrame(box));
|
|
|
|
holdFrames[kind] = samples[samples.Count / 2];
|
|
}
|
|
|
|
// SIX UNIQUE animations — every event's mid-hold frame differs from every other.
|
|
foreach (var a in eventTypes)
|
|
foreach (var b in eventTypes)
|
|
{
|
|
if (a == b) continue;
|
|
Assert.True(Hash(holdFrames[a].BgraPixels) != Hash(holdFrames[b].BgraPixels),
|
|
$"{a} and {b} must have distinct animations");
|
|
}
|
|
}
|
|
|
|
private static bool HasPixels(VideoFrame frame)
|
|
=> frame.BgraPixels.Any(b => b != 0);
|
|
|
|
private static bool AnyPairDiffer(List<VideoFrame> frames)
|
|
{
|
|
for (var i = 0; i < frames.Count; i++)
|
|
for (var j = i + 1; j < frames.Count; j++)
|
|
{
|
|
var f = frames[i].BgraPixels;
|
|
var g = frames[j].BgraPixels;
|
|
if (f.Length != g.Length || !f.SequenceEqual(g)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static long Hash(byte[] bytes)
|
|
{
|
|
var hash = 1469598103934665603L;
|
|
foreach (var b in bytes)
|
|
{
|
|
hash ^= b;
|
|
hash *= 1099511628211L;
|
|
}
|
|
return hash;
|
|
}
|
|
} |