From 6b6ccd7b0a15f003713ae1b1fb62db5fdef84477 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 14:36:13 -0700 Subject: [PATCH] =?UTF-8?q?feat(scoreboard):=20unit=202=20=E2=80=94=20sess?= =?UTF-8?q?ion=20lifecycle=20(start=20clears,=20End=20freezes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScoreboardRecorder owns the one scope the numbers promise. StartSession resets and marks running; Record ignores everything outside a session (a stale tally from the last stream is exactly the 'today' lie this design avoids); EndSession freezes the numbers but stops them moving, so an End screen can still show them. Changed fires only when a displayed value actually moves, which is what keeps the strip's raster cache honest. --- Models/ScoreboardRecorder.cs | 51 +++++++++++++++++++++++++ ytLive.Tests/ScoreboardRecorderTests.cs | 42 ++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 Models/ScoreboardRecorder.cs create mode 100644 ytLive.Tests/ScoreboardRecorderTests.cs 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); + } +}