diff --git a/Models/ScoreboardRecorder.cs b/Models/ScoreboardRecorder.cs new file mode 100644 index 0000000..aa24b56 --- /dev/null +++ b/Models/ScoreboardRecorder.cs @@ -0,0 +1,51 @@ +namespace ytLive.Models; + +/// Owns the session's and the one lifecycle rule that +/// matters: a new session starts from zero. +/// +/// Kept separate from the view-model so the rule is testable without a window, and so the +/// scoreboard layer has exactly one place that decides when counting begins. +public sealed class ScoreboardRecorder +{ + public ScoreboardState State { get; private set; } = new(); + + /// Called when a session starts (go-live, test, or a local recording). Every start + /// clears the tallies — "session" is the only scope the numbers promise. + public void StartSession() + { + State = new ScoreboardState(); + IsRunning = true; + RaiseChanged(); + } + + /// Folds a chat event in. Outside a session nothing is counted: a stale tally left over + /// from the last stream would be the "today" lie this design exists to avoid. + public void Record(ChatMessage message) + { + if (!IsRunning) return; + var before = Fingerprint(State); + State.Apply(message); + if (Fingerprint(State) != before) RaiseChanged(); + } + + /// True while the tallies are live. + public bool IsRunning { get; private set; } + + /// Marks the session finished. The tallies stay visible (the End screen may show + /// them) but stop moving. + public void EndSession() + { + IsRunning = false; + RaiseChanged(); + } + + public event System.Action? Changed; + + private void RaiseChanged() => Changed?.Invoke(); + + /// Cheap change detector: only re-render the strip when a displayed value moved. + private static string Fingerprint(ScoreboardState state) => + $"{state.NewMembers}|{state.MilestoneCount}|{state.LastMemberName}|{state.LastGifterName}|" + + $"{state.LastGiftCount}|{state.LastSuperChatAuthor}|{state.LastSuperChatDisplay}|" + + $"{state.SessionTotal.Currency}|{state.SessionTotal.Total:0.######}"; +} diff --git a/ytLive.Tests/ScoreboardRecorderTests.cs b/ytLive.Tests/ScoreboardRecorderTests.cs new file mode 100644 index 0000000..9ccc16d --- /dev/null +++ b/ytLive.Tests/ScoreboardRecorderTests.cs @@ -0,0 +1,42 @@ +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// The session is the only scope the scoreboard promises, so the rules are: a start clears +/// everything, events outside a session are ignored, and an ended session keeps its numbers but +/// stops moving. +public class ScoreboardRecorderTests +{ + private static ChatMessage Member(string name) => new() { Kind = ChatEventKind.NewMember, AuthorName = name }; + + [Fact] + public void Starting_a_session_clears_the_tallies_and_only_a_live_session_counts() + { + var recorder = new ScoreboardRecorder(); + var changes = 0; + recorder.Changed += () => changes++; + + // Outside a session: nothing is counted (no stale carry-over from the last stream). + recorder.Record(Member("Ignored")); + Assert.Equal(0, recorder.State.NewMembers); + + recorder.StartSession(); + Assert.True(recorder.IsRunning); + recorder.Record(Member("First")); + recorder.Record(Member("Second")); + Assert.Equal(2, recorder.State.NewMembers); + Assert.True(changes >= 2, "each visible change must raise Changed so the strip re-renders"); + + // End freezes: numbers stay, movement stops. + recorder.EndSession(); + Assert.False(recorder.IsRunning); + recorder.Record(Member("AfterEnd")); + Assert.Equal(2, recorder.State.NewMembers); + + // The next session starts from zero. + recorder.StartSession(); + Assert.Equal(0, recorder.State.NewMembers); + Assert.Null(recorder.State.LastMemberName); + } +}