diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 63011b2..8cd8816 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -790,6 +790,35 @@ + + + + + + + + + + + + + + + + One Super Chat or Super Sticker event, as YouTube reports it. +public sealed record SuperChatEvent( + string Id, + ChatEventKind Kind, + long AmountMicros, + string Currency, + string? DisplayAmount, + DateTime PublishedAt); + +/// +/// TASK 3·20: what this session's rewards added up to — in memory only. +/// Derived on demand from superChatEvents.list rather than persisted: YouTube is the system +/// of record for money, and keeping a local ledger would contradict the published promise that no +/// viewer, member or donor information is retained after the session ends. +/// Totals are kept per currency and never summed across currencies — a $5 and a €5 are not +/// $10, and a single blended number would be a lie. +/// +public sealed record SessionRewardSummary( + int SuperChatCount, + int SuperStickerCount, + IReadOnlyDictionary MicrosByCurrency, + DateTime? FirstEventAt, + DateTime? LastEventAt) +{ + public static readonly SessionRewardSummary Empty = new( + 0, 0, new Dictionary(), null, null); + + public int TotalEvents => SuperChatCount + SuperStickerCount; + + public bool HasEvents => TotalEvents > 0; + + /// Filters to a session window and rolls the events up. Events outside + /// [, ] are ignored, which is what makes a + /// report "this session" rather than "this channel". + public static SessionRewardSummary From( + IEnumerable events, DateTime sinceUtc, DateTime? untilUtc = null) + { + var until = untilUtc ?? DateTime.MaxValue; + var inWindow = events + .Where(e => e.PublishedAt >= sinceUtc && e.PublishedAt <= until) + .OrderBy(e => e.PublishedAt) + .ToList(); + + if (inWindow.Count == 0) return Empty; + + var byCurrency = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var e in inWindow) + { + var currency = string.IsNullOrWhiteSpace(e.Currency) ? "?" : e.Currency.ToUpperInvariant(); + byCurrency.TryGetValue(currency, out var total); + byCurrency[currency] = total + Math.Max(0, e.AmountMicros); + } + + return new SessionRewardSummary( + inWindow.Count(e => e.Kind == ChatEventKind.SuperChat), + inWindow.Count(e => e.Kind == ChatEventKind.SuperSticker), + byCurrency, + inWindow[0].PublishedAt, + inWindow[^1].PublishedAt); + } + + /// Money lines for display, e.g. "USD $12.34 · EUR €4.00". Empty when there is nothing to + /// show — a creator who never monetized must never be shown a zero. + public IReadOnlyList MoneyLines() + => MicrosByCurrency + .Where(pair => pair.Value > 0) + .OrderByDescending(pair => pair.Value) + .Select(pair => $"{pair.Key} {Format(pair.Key, pair.Value)}") + .ToList(); + + private static string Format(string currency, long micros) + { + var amount = micros / 1_000_000m; + var symbol = currency switch + { + "USD" => "$", + "EUR" => "€", + "GBP" => "£", + "CAD" => "CA$", + "AUD" => "A$", + _ => string.Empty, + }; + return symbol.Length > 0 + ? symbol + amount.ToString("0.00", CultureInfo.InvariantCulture) + : amount.ToString("0.00", CultureInfo.InvariantCulture) + " " + currency; + } +} diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index 3397471..fcdaf09 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -57,6 +57,12 @@ public partial class LayoutStore : IDisposable public void SaveSteamLicenceIndicatorDismissed(bool dismissed) => UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0"); + /// TASK 3·20: show money in the app's own analytics surfaces. OFF by default — most + /// creators never monetize, and a $0.00 report is a spit in the face (creator ruling 2026-11-05). + public bool LoadShowMonetaryMetrics() => GetSetting("Metrics.ShowMonetary") == "1"; + + public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0"); + public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1"; public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0"); diff --git a/Services/SuperChatEventService.cs b/Services/SuperChatEventService.cs new file mode 100644 index 0000000..c643ae4 --- /dev/null +++ b/Services/SuperChatEventService.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ytLive.Helpers; +using ytLive.Models; + +namespace ytLive.Services; + +/// +/// TASK 3·20: reads the channel's recent Super Chat / Super Sticker events so a session report can be +/// shown without storing anything. +/// YouTube is the authority. This service only asks; it keeps no ledger, and the events it +/// returns are the ones YouTube still considers valid (removed or refunded events do not come back). +/// Scope: the endpoint is authorised by the app's existing auth/youtube scope, so no +/// re-consent is needed — unlike the Analytics API, which does. +/// +public interface ISuperChatEventSource +{ + /// The most recent events for a channel (YouTube caps the window at roughly 30 days). + Task> GetRecentAsync( + string channelId, int maxResults = 200, CancellationToken cancellationToken = default); +} + +public sealed class SuperChatEventService : ISuperChatEventSource +{ + private const string ApiBase = "https://www.googleapis.com/youtube/v3"; + private readonly YouTubeAuthService _auth; + private readonly HttpClient _http; + + public SuperChatEventService(YouTubeAuthService auth, HttpClient? http = null) + { + _auth = auth; + _http = http ?? new HttpClient(); + } + + public async Task> GetRecentAsync( + string channelId, int maxResults = 200, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(channelId)) return Array.Empty(); + if (_auth.CurrentChannel?.AccessToken is not { } token) return Array.Empty(); + + _http.DefaultRequestHeaders.Authorization = new("Bearer", token); + var url = $"{ApiBase}/superChatEvents?part=snippet&maxResults={Math.Clamp(maxResults, 1, 200)}" + + $"&channelId={Uri.EscapeDataString(channelId)}"; + try + { + var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + AppLog.Write($"superChatEvents.list failed: {(int)response.StatusCode}"); + return Array.Empty(); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return Parse(json); + } + catch (Exception ex) + { + AppLog.Write($"superChatEvents.list failed: {ex.Message}"); + return Array.Empty(); + } + } + + /// Pure parse of a superChatEvents.list response. Both event types carry their money + /// in a different object, so a sticker must not be silently dropped. + internal static IReadOnlyList Parse(string json) + { + var events = new List(); + using var document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty("items", out var items)) return events; + + foreach (var item in items.EnumerateArray()) + { + if (!item.TryGetProperty("snippet", out var snippet)) continue; + + var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty; + var publishedAt = snippet.TryGetProperty("publishedAt", out var published) + && DateTime.TryParse(published.GetString(), out var parsed) + ? parsed.ToUniversalTime() + : DateTime.UtcNow; + + var kind = ChatEventKind.SuperChat; + JsonElement details; + if (snippet.TryGetProperty("superChatDetails", out var chat)) + details = chat; + else if (snippet.TryGetProperty("superStickerDetails", out var sticker)) + { + details = sticker; + kind = ChatEventKind.SuperSticker; + } + else + { + continue; + } + + var micros = details.TryGetProperty("amountMicros", out var amount) + && amount.ValueKind == JsonValueKind.Number + && amount.TryGetInt64(out var value) + ? value + : 0; + var currency = details.TryGetProperty("currency", out var cur) && cur.ValueKind == JsonValueKind.String + ? cur.GetString() ?? string.Empty + : string.Empty; + var display = details.TryGetProperty("amountDisplayString", out var shown) + && shown.ValueKind == JsonValueKind.String + ? shown.GetString() + : null; + + events.Add(new SuperChatEvent(id, kind, micros, currency, display, publishedAt)); + } + + return events; + } +} diff --git a/Services/YppTrackerViewModel.cs b/Services/YppTrackerViewModel.cs index b86508b..efb7095 100644 --- a/Services/YppTrackerViewModel.cs +++ b/Services/YppTrackerViewModel.cs @@ -1,4 +1,7 @@ +using System; +using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Windows.Input; using ytLive.Helpers; using ytLive.Models; @@ -25,6 +28,12 @@ public sealed class YppTrackerViewModel : ViewModelBase private readonly Func _isConnected; private readonly Func _displayName; + // TASK 3·20 — the session report is DERIVED, never stored: ask YouTube for the channel's recent + // reward events and roll them up for the window that starts when this session did. + private readonly Func _rewardEvents; + private readonly Func _channelId; + private readonly Func _sessionStartedAtUtc; + private bool _isDrawerOpen; private bool _isLoading; private bool _isRefreshing; @@ -43,12 +52,18 @@ public sealed class YppTrackerViewModel : ViewModelBase Func store, Func stats, Func isConnected, - Func displayName) + Func displayName, + Func? rewardEvents = null, + Func? channelId = null, + Func? sessionStartedAtUtc = null) { _store = store; _stats = stats; _isConnected = isConnected; _displayName = displayName; + _rewardEvents = rewardEvents ?? (() => null!); + _channelId = channelId ?? (() => null); + _sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null); RefreshCommand = new RelayCommand(_ => _ = RefreshAsync()); ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen); @@ -58,6 +73,7 @@ public sealed class YppTrackerViewModel : ViewModelBase OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl)); (_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist(); + _showMonetaryMetrics = _store().LoadShowMonetaryMetrics(); _lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc(); } @@ -92,6 +108,63 @@ public sealed class YppTrackerViewModel : ViewModelBase public bool IsSignedOut => !_isConnected(); + private SessionRewardSummary _sessionRewards = SessionRewardSummary.Empty; + + /// This session's reward events, derived on demand. Never persisted. + public SessionRewardSummary SessionRewards + { + get => _sessionRewards; + private set + { + _sessionRewards = value; + OnPropertyChanged(nameof(SessionRewards)); + OnPropertyChanged(nameof(HasSessionRewards)); + OnPropertyChanged(nameof(SessionCountsText)); + OnPropertyChanged(nameof(SessionMoneyLines)); + OnPropertyChanged(nameof(HasSessionMoney)); + } + } + + public bool HasSessionRewards => _sessionRewards.HasEvents; + + /// Counts are always neutral — "3 Super Chats" says nothing about money, and most + /// creators have none. + public string SessionCountsText + { + get + { + if (!_sessionRewards.HasEvents) return string.Empty; + var parts = new List(); + if (_sessionRewards.SuperChatCount > 0) parts.Add($"{_sessionRewards.SuperChatCount} Super Chat{(_sessionRewards.SuperChatCount == 1 ? "" : "s")}"); + if (_sessionRewards.SuperStickerCount > 0) parts.Add($"{_sessionRewards.SuperStickerCount} Super Sticker{(_sessionRewards.SuperStickerCount == 1 ? "" : "s")}"); + return string.Join(" · ", parts); + } + } + + /// Money is shown only when the creator asked for it AND there is something to show. + /// A creator who never monetized never sees a zero (creator ruling 2026-11-05). + public IReadOnlyList SessionMoneyLines + => ShowMonetaryMetrics ? _sessionRewards.MoneyLines() : Array.Empty(); + + public bool HasSessionMoney => SessionMoneyLines.Count > 0; + + private bool _showMonetaryMetrics; + + /// "Show monetary metrics" — lives in this drawer, persisted, OFF by default. Turning it on + /// is also what would justify requesting the monetary Analytics scope; while it is off the app never + /// asks Google for money data. + public bool ShowMonetaryMetrics + { + get => _showMonetaryMetrics; + set + { + if (!SetProperty(ref _showMonetaryMetrics, value)) return; + _store().SaveShowMonetaryMetrics(value); + OnPropertyChanged(nameof(SessionMoneyLines)); + OnPropertyChanged(nameof(HasSessionMoney)); + } + } + public string? LoadError { get => _loadError; @@ -203,11 +276,38 @@ public sealed class YppTrackerViewModel : ViewModelBase await RefreshAsync(); } + /// Asks YouTube for the channel's recent reward events and keeps only this session's. + /// Nothing is written down: the report is a view over YouTube's data, not a local ledger. + private async Task RefreshSessionRewardsAsync() + { + var started = _sessionStartedAtUtc(); + var channel = _channelId(); + var source = _rewardEvents(); + if (started == null || string.IsNullOrWhiteSpace(channel) || source == null) + { + SessionRewards = SessionRewardSummary.Empty; + return; + } + + try + { + var events = await source.GetRecentAsync(channel); + SessionRewards = SessionRewardSummary.From(events, started.Value); + } + catch (Exception ex) + { + AppLog.Write($"Session rewards unavailable: {ex.Message}"); + SessionRewards = SessionRewardSummary.Empty; + } + } + public async Task RefreshAsync() { if (_isRefreshing) return; _isRefreshing = true; IsLoading = true; + + await RefreshSessionRewardsAsync(); LoadError = null; try { diff --git a/TASKS.md b/TASKS.md index 31000d0..2ba341b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -47,7 +47,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block). | — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) | | 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | | 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | -| 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) | +| 3·20 | ✅ | Session report — **derived from `superChatEvents.list` on demand, not persisted** (YouTube is the authority; no ledger). Per-currency totals, money off by default (done 2026-11-05) | [`task-03`](TASKS/task-03-capture-pipeline.md) | | 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING + ingest pre-flight done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air | [`task-32`](TASKS/task-32-stream-resilience.md) | | 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) | | 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) | diff --git a/TASKS/task-03-capture-pipeline.md b/TASKS/task-03-capture-pipeline.md index cea924b..1e642bc 100644 --- a/TASKS/task-03-capture-pipeline.md +++ b/TASKS/task-03-capture-pipeline.md @@ -35,10 +35,14 @@ **Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube emits no public event for free subs), NO viewer count (vetoed), and every card draws the "made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor - (still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table - (all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report - rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses - events into `ChatMessage` directly in memory; the report reads that table once it exists.** + ✅ **RESOLVED 2026-11-05 — derived, not persisted.** The creator's ruling: YouTube is the authority + for monetization, so the app asks it on demand instead of keeping a ledger. `Services/SuperChatEventService.cs` + reads `superChatEvents.list` (existing `auth/youtube` scope — no re-consent), `Models/SessionRewardSummary.cs` + rolls it up for the session window with **per-currency totals that are never summed together**, and the YPP + drawer shows "THIS SESSION" counts. No table, no migration, no backfill, and the published promise that no + viewer/member/donor information is retained stays literally true. + **Money is off by default** ("Show monetary metrics", in the drawer, persisted): counts are neutral, and a + creator who never monetized is never shown a zero. Tests: `SessionRewardReportTests`.** 21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem). 22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`. 23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear. diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 6829ecd..1b988e8 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -120,6 +120,9 @@ public partial class MainViewModel : ViewModelBase ? "LlamaCasty" : $"{StreamTitle} — LlamaCasty"; StreamStatus = StreamStatus.Streaming; + // TASK 3·20: the session report window starts here. Derived on demand from YouTube, never + // persisted, and reset when the session ends. + _sessionStartedAtUtc = DateTime.UtcNow; ResetHealth(StreamStatus.Streaming); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); _liveElapsed = TimeSpan.Zero; @@ -371,6 +374,7 @@ public partial class MainViewModel : ViewModelBase var wasRecording = IsRecording; StreamStatus = StreamStatus.Offline; + _sessionStartedAtUtc = null; IsRecording = false; LiveScene = null; EditingActive = true; diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index cdc3b75..0fb9c2d 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -416,6 +416,10 @@ public partial class MainViewModel : ViewModelBase set => SetProperty(ref _previewGlowThickness, value); } + /// When the current/last streaming session started — the window a session report covers. + /// Null when no session has run since launch. + private DateTime? _sessionStartedAtUtc; + public string LiveElapsedText { get => _liveElapsedText; diff --git a/ViewModels/MainViewModel.Ypp.cs b/ViewModels/MainViewModel.Ypp.cs index e880126..d1bf507 100644 --- a/ViewModels/MainViewModel.Ypp.cs +++ b/ViewModels/MainViewModel.Ypp.cs @@ -23,7 +23,10 @@ public partial class MainViewModel () => _layoutStore, () => ChannelStatsFactoryOverride?.Invoke() ?? new ChannelStatsService(_youtubeAuth), () => IsConnected, - () => _youtubeAuth.CurrentChannel?.DisplayName); + () => _youtubeAuth.CurrentChannel?.DisplayName, + rewardEvents: () => new SuperChatEventService(_youtubeAuth), + channelId: () => _youtubeAuth.CurrentChannel?.ChannelId, + sessionStartedAtUtc: () => _sessionStartedAtUtc); TestSession = new TestSessionViewModel( _youtubeStream, diff --git a/docs/ai/product.md b/docs/ai/product.md index a94d042..9cdcd46 100644 --- a/docs/ai/product.md +++ b/docs/ai/product.md @@ -298,6 +298,18 @@ comments removed in Q1 2026 alone, child safety the #2 reason at 124.6M). **No f — chat history, moderation logs, analytics, crash payloads — may quietly break that assumption.** If one is ever proposed, the 11.12 position must be re-argued first. +### Monetization: derived, never a ledger (2026-11-05) + +YouTube is the **sole authority** for monetization progress and qualification; the API exposes **no** +eligibility field (`channels.list` has no monetization part; `auditDetails` needs an MCN scope). So the app +**asks YouTube on demand** (`superChatEvents.list` for the session report; Analytics for the YPP bars in +TASK 39·2) and **stores nothing**. That keeps the promise above literally true — there is no reward ledger +to retain — and it means the app never has to model YouTube's moving thresholds as our own data. + +**Money is off by default** ("Show monetary metrics", in the YPP drawer, persisted): counts are neutral, +and a creator who never monetized is never shown a zero. Amounts are estimates that can be restated for +weeks, and are labelled as such wherever they appear. + ### Minors The **operator is 13+ by construction**, not by promise: LlamaCasty requires a YouTube diff --git a/ytLive.Tests/BackOnAirTests.cs b/ytLive.Tests/BackOnAirTests.cs index b34bc2b..b719351 100644 --- a/ytLive.Tests/BackOnAirTests.cs +++ b/ytLive.Tests/BackOnAirTests.cs @@ -12,6 +12,10 @@ namespace ytLive.Tests; /// route back is a NEW broadcast: one click, saved details, no dialog, and a title that says it is a /// resume (otherwise the channel grows two identically named videos). /// +// Constructs a MainViewModel directly, and `LayoutPathOverride` is static: it belongs in the serialised +// collection with the other direct-construction tests, or a parallel sibling can swap the layout path +// underneath it (observed as a one-off failure in a full run, green in isolation). +[Collection("MainViewModelLayoutPath")] public class BackOnAirTests { [Fact] diff --git a/ytLive.Tests/SessionRewardReportTests.cs b/ytLive.Tests/SessionRewardReportTests.cs new file mode 100644 index 0000000..c60f411 --- /dev/null +++ b/ytLive.Tests/SessionRewardReportTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using ytLive.Models; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 3·20 — the session report is DERIVED from YouTube, never stored: no ledger, no migration, and the +/// published promise ("no viewer, member or donor information is retained after the session ends") stays +/// literally true. +/// Two rules the creator set (2026-11-05): money is hidden unless asked for, and a creator who never +/// monetized is never shown a zero. Counts stay neutral so a normal session still has something to read. +/// +public class SessionRewardReportTests +{ + private static readonly DateTime SessionStart = new(2026, 11, 5, 20, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ParsesBothRewardTypes_WithoutDroppingStickers() + { + const string json = """ + { + "items": [ + {"id":"sc1","snippet":{"publishedAt":"2026-11-05T20:05:00Z","superChatDetails": + {"amountMicros":5000000,"currency":"USD","amountDisplayString":"$5.00","userComment":"hi"}}}, + {"id":"ss1","snippet":{"publishedAt":"2026-11-05T20:07:00Z","superStickerDetails": + {"amountMicros":2000000,"currency":"EUR","amountDisplayString":"€2.00"}}} + ] + } + """; + + var events = SuperChatEventService.Parse(json); + + Assert.Equal(2, events.Count); + Assert.Equal(ChatEventKind.SuperChat, events[0].Kind); + Assert.Equal(5_000_000, events[0].AmountMicros); + Assert.Equal("USD", events[0].Currency); + Assert.Equal(ChatEventKind.SuperSticker, events[1].Kind); + Assert.Equal("EUR", events[1].Currency); + } + + [Fact] + public void TheWindowMakesItASessionReport_NotAChannelReport() + { + var events = new[] + { + Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(-30)), // before + Event(ChatEventKind.SuperChat, 3_000_000, "USD", SessionStart.AddMinutes(10)), + Event(ChatEventKind.SuperSticker, 2_000_000, "USD", SessionStart.AddMinutes(20)), + }; + + var summary = SessionRewardSummary.From(events, SessionStart); + + Assert.Equal(1, summary.SuperChatCount); + Assert.Equal(1, summary.SuperStickerCount); + Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]); + } + + [Fact] + public void CurrenciesAreNeverSummedTogether() + { + var events = new[] + { + Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(1)), + Event(ChatEventKind.SuperChat, 5_000_000, "EUR", SessionStart.AddMinutes(2)), + }; + + var summary = SessionRewardSummary.From(events, SessionStart); + + Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]); + Assert.Equal(5_000_000, summary.MicrosByCurrency["EUR"]); + Assert.Equal(2, summary.MoneyLines().Count); + Assert.Contains(summary.MoneyLines(), line => line.StartsWith("USD $5.00")); + Assert.Contains(summary.MoneyLines(), line => line.StartsWith("EUR €5.00")); + } + + [Fact] + public void AnEmptySession_HasNoCountsAndNoMoney() + { + var summary = SessionRewardSummary.From(Array.Empty(), SessionStart); + + Assert.False(summary.HasEvents); + Assert.Empty(summary.MoneyLines()); + Assert.Equal(0, summary.TotalEvents); + } + + [Fact] + public void AStickerOnlySession_StillCounts() + { + var summary = SessionRewardSummary.From( + new[] { Event(ChatEventKind.SuperSticker, 1_000_000, "USD", SessionStart.AddMinutes(1)) }, + SessionStart); + + Assert.True(summary.HasEvents); + Assert.Equal(0, summary.SuperChatCount); + Assert.Equal(1, summary.SuperStickerCount); + } + + private static SuperChatEvent Event(ChatEventKind kind, long micros, string currency, DateTime at) + => new($"id-{Guid.NewGuid():N}", kind, micros, currency, null, at); +}