using System; using System.Collections.Generic; using System.Linq; using Xunit; using ytLive.Models; using ytLive.Services; namespace ytLive.Tests; /// /// 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. /// [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(); foreach (var kind in eventTypes) { layer.Enqueue(byKind[kind]); Assert.True(layer.IsPlaying, $"{kind} should be playing after enqueue"); var samples = new List(); 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 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; } }