Files
LlamaCasty/ytLive.Tests/ChatModerationParseTests.cs
T
gramps a814bcd502 fix(chat): stop painting what YouTube removed, and retire the local word filter
TASK 49 is retired (creator ruling): YouTube owns chat moderation. Its blocked-words filter and
hold-for-review run upstream of `liveChatMessages.list` — held messages are never returned
(https://stackoverflow.com/questions/74511524/) — so a second local list would duplicate a switch
the creator already owns, with a second set of false positives and a second support surface.

What we actually owed the platform, and had wrong:

- The parser added every item to the overlay. A deleted message arrives as a `tombstone` carrying
  no text at all ("it has since been deleted … only liveChatId, type and publishedAt are present" —
  https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages), so every
  deletion rendered as a BLANK chat row. `hasDisplayContent:false` messages and chat-mode events
  did the same.
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented
  but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/),
  so nothing is built on them; they are skipped if they ever arrive.
- Found while testing: Super Chat read `displayString`, but YouTube's field is `amountDisplayString`
  — every amount silently fell back to a recomposed string, which mangles non-dollar locales. The
  model's own comment said this was the reason the field exists.

Test: `ChatModerationParseTests` — tombstone, no-display-content, chat-mode events all produce no
row; a real Super Chat still parses its pre-rendered amount (constraint 4: reward fields are never
reinterpreted).

Docs: TASK 49 marked retired with the citations, the rule recorded in decisions.md, HANDOFF updated.
verify.sh: 550 passed, 0 failed, 0 warnings.
2026-11-05 08:39:46 -08:00

98 lines
3.5 KiB
C#

using System.Linq;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// 2026-11-05 ruling (retires TASK 49's local wordlist): YouTube owns chat moderation, so the
/// behaviour we owe it is not a second list — it is <b>not showing what YouTube has removed</b>.
/// The feed sends deleted messages as a <c>tombstone</c> placeholder carrying no text, and marks
/// non-displayable events with <c>hasDisplayContent: false</c>; both used to become blank rows on
/// the overlay (the parser had no filter at all).
/// </summary>
public class ChatModerationParseTests
{
[Fact]
public void Tombstone_NeverBecomesAChatRow()
{
const string json = """
{
"items": [
{"id":"d1","snippet":{"type":"tombstone","liveChatId":"c","publishedAt":"2026-11-05T10:00:00.000Z"}},
{"id":"m1","snippet":{"type":"textMessageEvent","hasDisplayContent":true,
"textMessageDetails":{"messageText":"hello"},"publishedAt":"2026-11-05T10:00:01.000Z"},
"authorDetails":{"displayName":"Viewer","channelId":"UC1"}}
],
"nextPageToken":"n"
}
""";
var page = YouTubeChatService.ParsePage(json);
Assert.Single(page.Messages);
Assert.Equal("hello", page.Messages[0].Message);
}
[Fact]
public void MessagesWithoutDisplayContent_AreSkipped()
{
const string json = """
{
"items": [
{"id":"m1","snippet":{"type":"textMessageEvent","hasDisplayContent":false,
"publishedAt":"2026-11-05T10:00:00.000Z"},
"authorDetails":{"displayName":"Muted","channelId":"UC1"}},
{"id":"m2","snippet":{"type":"textMessageEvent","hasDisplayContent":true,
"textMessageDetails":{"messageText":"visible"},"publishedAt":"2026-11-05T10:00:01.000Z"},
"authorDetails":{"displayName":"Viewer","channelId":"UC2"}}
]
}
""";
var page = YouTubeChatService.ParsePage(json);
Assert.Single(page.Messages);
Assert.Equal("visible", page.Messages[0].Message);
}
[Fact]
public void ChatModeEvents_AreNotChat()
{
const string json = """
{
"items": [
{"id":"e1","snippet":{"type":"sponsorOnlyModeStartedEvent","hasDisplayContent":false,
"publishedAt":"2026-11-05T10:00:00.000Z"}},
{"id":"e2","snippet":{"type":"chatEndedEvent","publishedAt":"2026-11-05T10:00:01.000Z"}}
]
}
""";
Assert.Empty(YouTubeChatService.ParsePage(json).Messages);
}
[Fact]
public void RealChatStillParses_WithAuthorAndSuperChat()
{
const string json = """
{
"items": [
{"id":"m1","snippet":{"type":"superChatEvent","hasDisplayContent":true,
"superChatDetails":{"amountMicros":12340000,"currency":"USD","amountDisplayString":"$12.34",
"userComment":"nice stream"},"publishedAt":"2026-11-05T10:00:00.000Z"},
"authorDetails":{"displayName":"Funder","channelId":"UC9","isChatSponsor":true}}
]
}
""";
var message = Assert.Single(YouTubeChatService.ParsePage(json).Messages);
Assert.Equal("Funder", message.AuthorName);
Assert.True(message.IsSuperChat);
// Constraint that survives the TASK 49 retirement: reward fields are never reinterpreted.
Assert.Equal("$12.34", message.SuperChatDisplayString);
Assert.Equal("nice stream", message.Message);
}
}